seqpulse 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -11
- package/index.d.ts +87 -2
- package/index.js +287 -16
- package/package.json +1 -1
- package/scripts/smoke.js +63 -2
package/README.md
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# seqpulse (Node.js SDK)
|
|
2
2
|
|
|
3
|
-
SeqPulse SDK
|
|
3
|
+
SeqPulse Node SDK couvre deux usages:
|
|
4
4
|
|
|
5
|
-
- HTTP metrics
|
|
6
|
-
-
|
|
7
|
-
- optional HMAC v2 validation on inbound SeqPulse calls
|
|
5
|
+
- Runtime application: instrumentation HTTP + endpoint metrics + validation HMAC v2
|
|
6
|
+
- CI/CD integration: client `trigger/finish` pour orchestrer un deployment SeqPulse
|
|
8
7
|
|
|
9
8
|
## Install
|
|
10
9
|
|
|
@@ -14,7 +13,7 @@ npm install seqpulse
|
|
|
14
13
|
pnpm add seqpulse
|
|
15
14
|
```
|
|
16
15
|
|
|
17
|
-
## Express
|
|
16
|
+
## Runtime (Express)
|
|
18
17
|
|
|
19
18
|
```js
|
|
20
19
|
const express = require("express");
|
|
@@ -23,7 +22,6 @@ const seqpulse = require("seqpulse");
|
|
|
23
22
|
const app = express();
|
|
24
23
|
|
|
25
24
|
seqpulse.init({
|
|
26
|
-
apiKey: process.env.SEQPULSE_API_KEY,
|
|
27
25
|
endpoint: "/seqpulse-metrics",
|
|
28
26
|
hmacEnabled: process.env.SEQPULSE_HMAC_ENABLED === "true",
|
|
29
27
|
hmacSecret: process.env.SEQPULSE_HMAC_SECRET,
|
|
@@ -38,8 +36,6 @@ app.listen(3000, () => {
|
|
|
38
36
|
});
|
|
39
37
|
```
|
|
40
38
|
|
|
41
|
-
## Returned payload
|
|
42
|
-
|
|
43
39
|
`GET /seqpulse-metrics` returns:
|
|
44
40
|
|
|
45
41
|
```json
|
|
@@ -54,10 +50,50 @@ app.listen(3000, () => {
|
|
|
54
50
|
}
|
|
55
51
|
```
|
|
56
52
|
|
|
57
|
-
##
|
|
53
|
+
## CI/CD client (trigger/finish)
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const seqpulse = require("seqpulse");
|
|
57
|
+
|
|
58
|
+
const client = seqpulse.createCIClient({
|
|
59
|
+
baseUrl: process.env.SEQPULSE_BASE_URL,
|
|
60
|
+
apiKey: process.env.SEQPULSE_API_KEY,
|
|
61
|
+
metricsEndpoint: process.env.SEQPULSE_METRICS_ENDPOINT,
|
|
62
|
+
env: "prod",
|
|
63
|
+
nonBlocking: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
async function runDeployment() {
|
|
67
|
+
const trigger = await client.trigger({
|
|
68
|
+
branch: process.env.GITHUB_REF_NAME,
|
|
69
|
+
idempotencyKey: `gha-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT}`,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Deploy your app here...
|
|
73
|
+
const deploySucceeded = true;
|
|
74
|
+
|
|
75
|
+
if (trigger.ok && trigger.deploymentId) {
|
|
76
|
+
await client.finish({
|
|
77
|
+
deploymentId: trigger.deploymentId,
|
|
78
|
+
result: deploySucceeded ? "success" : "failed",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### CI behavior
|
|
85
|
+
|
|
86
|
+
- `nonBlocking: true` (default): returns a skipped/error result instead of throwing.
|
|
87
|
+
- `nonBlocking: false`: throws on API/config errors.
|
|
88
|
+
- idempotency key helper available: `seqpulse.buildCiIdempotencyKey()`.
|
|
89
|
+
|
|
90
|
+
## Compatibility note
|
|
91
|
+
|
|
92
|
+
Cette evolution **n'ecrase pas** le SDK runtime actuel:
|
|
58
93
|
|
|
59
|
-
-
|
|
60
|
-
-
|
|
94
|
+
- l'endpoint `/seqpulse-metrics` reste le meme
|
|
95
|
+
- la logique HMAC runtime reste la meme
|
|
96
|
+
- le client CI est ajoute en couche supplementaire
|
|
61
97
|
|
|
62
98
|
## Local smoke test
|
|
63
99
|
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export type SeqPulseInitConfig = {
|
|
2
|
-
apiKey
|
|
2
|
+
apiKey?: string
|
|
3
3
|
endpoint?: string
|
|
4
|
-
windowSeconds?: number
|
|
5
4
|
hmacEnabled?: boolean
|
|
6
5
|
hmacSecret?: string
|
|
7
6
|
maxSkewPastSeconds?: number
|
|
@@ -16,10 +15,96 @@ export type SeqPulseMetrics = {
|
|
|
16
15
|
memory_usage: number
|
|
17
16
|
}
|
|
18
17
|
|
|
18
|
+
export type SeqPulseCiTransportRequest = {
|
|
19
|
+
url: string
|
|
20
|
+
headers: Record<string, string>
|
|
21
|
+
body: string
|
|
22
|
+
timeoutMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SeqPulseCiTransportResponse = {
|
|
26
|
+
status: number
|
|
27
|
+
text: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SeqPulseCiTransport = (
|
|
31
|
+
request: SeqPulseCiTransportRequest
|
|
32
|
+
) => Promise<SeqPulseCiTransportResponse>
|
|
33
|
+
|
|
34
|
+
export type SeqPulseCiConfig = {
|
|
35
|
+
baseUrl?: string
|
|
36
|
+
apiKey?: string
|
|
37
|
+
metricsEndpoint?: string
|
|
38
|
+
env?: string
|
|
39
|
+
timeoutMs?: number
|
|
40
|
+
nonBlocking?: boolean
|
|
41
|
+
transport?: SeqPulseCiTransport
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SeqPulseTriggerOptions = SeqPulseCiConfig & {
|
|
45
|
+
env?: string
|
|
46
|
+
idempotencyKey?: string
|
|
47
|
+
branch?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type SeqPulseFinishOptions = SeqPulseCiConfig & {
|
|
51
|
+
deploymentId?: string
|
|
52
|
+
result?: "success" | "failed" | string
|
|
53
|
+
pipelineStatus?: string
|
|
54
|
+
jobStatus?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SeqPulseCiSuccess = {
|
|
58
|
+
ok: true
|
|
59
|
+
skipped: false
|
|
60
|
+
action: "trigger" | "finish"
|
|
61
|
+
httpStatus: number
|
|
62
|
+
status?: string
|
|
63
|
+
message?: string | null
|
|
64
|
+
data: Record<string, any>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type SeqPulseTriggerSuccess = SeqPulseCiSuccess & {
|
|
68
|
+
action: "trigger"
|
|
69
|
+
deploymentId: string
|
|
70
|
+
status: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type SeqPulseCiError = {
|
|
74
|
+
ok: false
|
|
75
|
+
skipped: true
|
|
76
|
+
action: string
|
|
77
|
+
httpStatus: number
|
|
78
|
+
error: string
|
|
79
|
+
responseText: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type SeqPulseCiResult = SeqPulseCiSuccess | SeqPulseCiError
|
|
83
|
+
export type SeqPulseTriggerResult = SeqPulseTriggerSuccess | SeqPulseCiError
|
|
84
|
+
|
|
85
|
+
export type SeqPulseCiClient = {
|
|
86
|
+
trigger(options?: SeqPulseTriggerOptions): Promise<SeqPulseTriggerResult>
|
|
87
|
+
finish(options: SeqPulseFinishOptions): Promise<SeqPulseCiResult>
|
|
88
|
+
}
|
|
89
|
+
|
|
19
90
|
export type SeqPulseInstance = {
|
|
20
91
|
init(config: SeqPulseInitConfig): void
|
|
21
92
|
metrics(): (req: any, res: any, next: () => void) => void
|
|
22
93
|
getMetricsSnapshot(): SeqPulseMetrics
|
|
94
|
+
|
|
95
|
+
createCIClient(config?: SeqPulseCiConfig): SeqPulseCiClient
|
|
96
|
+
triggerDeployment(options?: SeqPulseTriggerOptions): Promise<SeqPulseTriggerResult>
|
|
97
|
+
finishDeployment(options: SeqPulseFinishOptions): Promise<SeqPulseCiResult>
|
|
98
|
+
buildCiIdempotencyKey(): string
|
|
99
|
+
inferResultFromPipelineStatus(status?: string): "success" | "failed"
|
|
100
|
+
|
|
101
|
+
ci: {
|
|
102
|
+
createClient(config?: SeqPulseCiConfig): SeqPulseCiClient
|
|
103
|
+
trigger(options?: SeqPulseTriggerOptions): Promise<SeqPulseTriggerResult>
|
|
104
|
+
finish(options: SeqPulseFinishOptions): Promise<SeqPulseCiResult>
|
|
105
|
+
buildIdempotencyKey(): string
|
|
106
|
+
inferResultFromPipelineStatus(status?: string): "success" | "failed"
|
|
107
|
+
}
|
|
23
108
|
}
|
|
24
109
|
|
|
25
110
|
declare const seqpulse: SeqPulseInstance
|
package/index.js
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
const crypto = require("node:crypto");
|
|
4
4
|
const os = require("node:os");
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const SAMPLE_WINDOW_SECONDS = 60;
|
|
7
7
|
const DEFAULT_ENDPOINT = "/seqpulse-metrics";
|
|
8
8
|
const DEFAULT_MAX_SKEW_PAST_SECONDS = 300;
|
|
9
9
|
const DEFAULT_MAX_SKEW_FUTURE_SECONDS = 30;
|
|
10
|
+
const DEFAULT_CI_TIMEOUT_MS = 4000;
|
|
10
11
|
|
|
11
12
|
const state = {
|
|
12
13
|
initialized: false,
|
|
13
14
|
config: {
|
|
14
15
|
apiKey: "",
|
|
15
16
|
endpoint: DEFAULT_ENDPOINT,
|
|
16
|
-
windowSeconds: DEFAULT_WINDOW_SECONDS,
|
|
17
17
|
hmacEnabled: false,
|
|
18
18
|
hmacSecret: "",
|
|
19
19
|
maxSkewPastSeconds: DEFAULT_MAX_SKEW_PAST_SECONDS,
|
|
@@ -31,6 +31,11 @@ function canonicalizePath(path) {
|
|
|
31
31
|
return normalized;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function normalizeBaseUrl(baseUrl) {
|
|
35
|
+
if (!baseUrl) return "";
|
|
36
|
+
return String(baseUrl).replace(/\/+$/, "");
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
function nowMs() {
|
|
35
40
|
return Date.now();
|
|
36
41
|
}
|
|
@@ -48,7 +53,7 @@ function round(value, digits) {
|
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
function cleanupOldSamples() {
|
|
51
|
-
const cutoff = nowMs() -
|
|
56
|
+
const cutoff = nowMs() - SAMPLE_WINDOW_SECONDS * 1000;
|
|
52
57
|
state.samples = state.samples.filter((item) => item.atMs >= cutoff);
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -123,7 +128,6 @@ function validateHmacRequest(req, res) {
|
|
|
123
128
|
function getMetricsSnapshot() {
|
|
124
129
|
cleanupOldSamples();
|
|
125
130
|
const sampleCount = state.samples.length;
|
|
126
|
-
const windowSeconds = state.config.windowSeconds;
|
|
127
131
|
const totalErrors = state.samples.reduce((acc, item) => acc + (item.isError ? 1 : 0), 0);
|
|
128
132
|
const latencies = state.samples.map((item) => item.latencyMs);
|
|
129
133
|
|
|
@@ -131,7 +135,7 @@ function getMetricsSnapshot() {
|
|
|
131
135
|
const memoryUsageRaw = os.totalmem() > 0 ? process.memoryUsage().rss / os.totalmem() : 0;
|
|
132
136
|
|
|
133
137
|
return {
|
|
134
|
-
requests_per_sec: round(sampleCount /
|
|
138
|
+
requests_per_sec: round(sampleCount / SAMPLE_WINDOW_SECONDS, 3),
|
|
135
139
|
latency_p95: round(percentile95(latencies), 3),
|
|
136
140
|
error_rate: sampleCount > 0 ? round(totalErrors / sampleCount, 6) : 0,
|
|
137
141
|
cpu_usage: round(Math.min(Math.max(cpuUsageRaw, 0), 1), 6),
|
|
@@ -143,27 +147,18 @@ function init(config) {
|
|
|
143
147
|
if (!config || typeof config !== "object") {
|
|
144
148
|
throw new Error("seqpulse.init(config) requires a config object");
|
|
145
149
|
}
|
|
146
|
-
if (!config.apiKey || typeof config.apiKey !== "string") {
|
|
147
|
-
throw new Error("seqpulse.init requires apiKey");
|
|
148
|
-
}
|
|
149
150
|
|
|
150
151
|
const endpoint = canonicalizePath(config.endpoint || DEFAULT_ENDPOINT);
|
|
151
|
-
const windowSeconds = Number(config.windowSeconds || DEFAULT_WINDOW_SECONDS);
|
|
152
152
|
const hmacEnabled = Boolean(config.hmacEnabled);
|
|
153
153
|
const hmacSecret = String(config.hmacSecret || "");
|
|
154
154
|
|
|
155
|
-
if (!Number.isFinite(windowSeconds) || windowSeconds <= 0) {
|
|
156
|
-
throw new Error("windowSeconds must be a positive number");
|
|
157
|
-
}
|
|
158
155
|
if (hmacEnabled && !hmacSecret) {
|
|
159
156
|
throw new Error("hmacSecret is required when hmacEnabled=true");
|
|
160
157
|
}
|
|
161
158
|
|
|
162
159
|
state.config = {
|
|
163
|
-
apiKey: config.apiKey,
|
|
164
|
-
endpoint,
|
|
165
|
-
windowSeconds,
|
|
166
|
-
hmacEnabled,
|
|
160
|
+
apiKey: typeof config.apiKey === "string" ? config.apiKey : "",
|
|
161
|
+
endpoint, hmacEnabled,
|
|
167
162
|
hmacSecret,
|
|
168
163
|
maxSkewPastSeconds: Number(config.maxSkewPastSeconds || DEFAULT_MAX_SKEW_PAST_SECONDS),
|
|
169
164
|
maxSkewFutureSeconds: Number(config.maxSkewFutureSeconds || DEFAULT_MAX_SKEW_FUTURE_SECONDS),
|
|
@@ -200,8 +195,284 @@ function metrics() {
|
|
|
200
195
|
};
|
|
201
196
|
}
|
|
202
197
|
|
|
198
|
+
function safeJsonParse(text) {
|
|
199
|
+
if (!text) return null;
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(text);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function inferResultFromPipelineStatus(status) {
|
|
208
|
+
const normalized = String(status || "").toLowerCase().trim();
|
|
209
|
+
if (["success", "ok", "passed", "pass"].includes(normalized)) return "success";
|
|
210
|
+
return "failed";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildCiIdempotencyKey() {
|
|
214
|
+
if (process.env.GITHUB_RUN_ID) {
|
|
215
|
+
return `gha-${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_ATTEMPT || "1"}`;
|
|
216
|
+
}
|
|
217
|
+
if (process.env.CI_PIPELINE_ID) {
|
|
218
|
+
return `gl-${process.env.CI_PIPELINE_ID}-${process.env.CI_PIPELINE_IID || "1"}`;
|
|
219
|
+
}
|
|
220
|
+
if (process.env.CIRCLE_WORKFLOW_ID) {
|
|
221
|
+
return `circle-${process.env.CIRCLE_WORKFLOW_ID}-${process.env.CIRCLE_BUILD_NUM || "1"}`;
|
|
222
|
+
}
|
|
223
|
+
if (process.env.BUILD_ID || process.env.BUILD_TAG) {
|
|
224
|
+
return `jenkins-${process.env.BUILD_TAG || process.env.BUILD_ID}`;
|
|
225
|
+
}
|
|
226
|
+
return `seqpulse-${nowMs()}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function inferBranchName() {
|
|
230
|
+
return (
|
|
231
|
+
process.env.GITHUB_REF_NAME ||
|
|
232
|
+
process.env.CI_COMMIT_REF_NAME ||
|
|
233
|
+
process.env.CIRCLE_BRANCH ||
|
|
234
|
+
process.env.BRANCH_NAME ||
|
|
235
|
+
"unknown"
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function defaultCiTransport({ url, headers, body, timeoutMs }) {
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetch(url, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers,
|
|
246
|
+
body,
|
|
247
|
+
signal: controller.signal,
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
status: response.status,
|
|
251
|
+
text: await response.text(),
|
|
252
|
+
};
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function createErrorResult({ action, error, nonBlocking, httpStatus = 0, responseText = "" }) {
|
|
259
|
+
const message = `${action} failed: ${error}`;
|
|
260
|
+
if (!nonBlocking) {
|
|
261
|
+
throw new Error(message);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
skipped: true,
|
|
266
|
+
action,
|
|
267
|
+
httpStatus,
|
|
268
|
+
error: String(error),
|
|
269
|
+
responseText,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function createCIClient(config = {}) {
|
|
274
|
+
const defaults = {
|
|
275
|
+
baseUrl: normalizeBaseUrl(config.baseUrl || process.env.SEQPULSE_BASE_URL || ""),
|
|
276
|
+
apiKey: String(config.apiKey || process.env.SEQPULSE_API_KEY || ""),
|
|
277
|
+
metricsEndpoint: String(config.metricsEndpoint || process.env.SEQPULSE_METRICS_ENDPOINT || ""),
|
|
278
|
+
env: String(config.env || "prod"),
|
|
279
|
+
timeoutMs: Number(config.timeoutMs || DEFAULT_CI_TIMEOUT_MS),
|
|
280
|
+
nonBlocking: config.nonBlocking !== false,
|
|
281
|
+
transport: typeof config.transport === "function" ? config.transport : defaultCiTransport,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
async function request(path, payload, options = {}) {
|
|
285
|
+
const action = options.action || path;
|
|
286
|
+
const nonBlocking = options.nonBlocking !== undefined ? options.nonBlocking : defaults.nonBlocking;
|
|
287
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl || defaults.baseUrl);
|
|
288
|
+
const apiKey = String(options.apiKey || defaults.apiKey || "");
|
|
289
|
+
const timeoutMs = Number(options.timeoutMs || defaults.timeoutMs);
|
|
290
|
+
|
|
291
|
+
if (!baseUrl || !apiKey) {
|
|
292
|
+
return createErrorResult({
|
|
293
|
+
action,
|
|
294
|
+
nonBlocking,
|
|
295
|
+
error: "missing baseUrl/apiKey",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const url = `${baseUrl}${path}`;
|
|
300
|
+
const body = JSON.stringify(payload);
|
|
301
|
+
|
|
302
|
+
let transportResponse;
|
|
303
|
+
try {
|
|
304
|
+
transportResponse = await defaults.transport({
|
|
305
|
+
url,
|
|
306
|
+
headers: {
|
|
307
|
+
"X-API-Key": apiKey,
|
|
308
|
+
"Content-Type": "application/json",
|
|
309
|
+
},
|
|
310
|
+
body,
|
|
311
|
+
timeoutMs,
|
|
312
|
+
});
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return createErrorResult({
|
|
315
|
+
action,
|
|
316
|
+
nonBlocking,
|
|
317
|
+
error: error && error.message ? error.message : String(error),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const httpStatus = Number(transportResponse && transportResponse.status ? transportResponse.status : 0);
|
|
322
|
+
const responseText = (transportResponse && transportResponse.text) || "";
|
|
323
|
+
const data = safeJsonParse(responseText);
|
|
324
|
+
|
|
325
|
+
if (!httpStatus) {
|
|
326
|
+
return createErrorResult({
|
|
327
|
+
action,
|
|
328
|
+
nonBlocking,
|
|
329
|
+
error: "unreachable",
|
|
330
|
+
httpStatus: 0,
|
|
331
|
+
responseText,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (httpStatus >= 400) {
|
|
336
|
+
return createErrorResult({
|
|
337
|
+
action,
|
|
338
|
+
nonBlocking,
|
|
339
|
+
error: `http ${httpStatus}`,
|
|
340
|
+
httpStatus,
|
|
341
|
+
responseText,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
ok: true,
|
|
347
|
+
skipped: false,
|
|
348
|
+
action,
|
|
349
|
+
httpStatus,
|
|
350
|
+
data: data || {},
|
|
351
|
+
responseText,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function trigger(options = {}) {
|
|
356
|
+
const nonBlocking = options.nonBlocking !== undefined ? options.nonBlocking : defaults.nonBlocking;
|
|
357
|
+
const metricsEndpoint = String(options.metricsEndpoint || defaults.metricsEndpoint || "");
|
|
358
|
+
|
|
359
|
+
if (!metricsEndpoint) {
|
|
360
|
+
return createErrorResult({
|
|
361
|
+
action: "trigger",
|
|
362
|
+
nonBlocking,
|
|
363
|
+
error: "missing metricsEndpoint",
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const payload = {
|
|
368
|
+
env: String(options.env || defaults.env || "prod"),
|
|
369
|
+
metrics_endpoint: metricsEndpoint,
|
|
370
|
+
idempotency_key: String(options.idempotencyKey || buildCiIdempotencyKey()),
|
|
371
|
+
branch: String(options.branch || inferBranchName()),
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const result = await request("/deployments/trigger", payload, {
|
|
375
|
+
...options,
|
|
376
|
+
action: "trigger",
|
|
377
|
+
nonBlocking,
|
|
378
|
+
});
|
|
379
|
+
if (!result.ok) return result;
|
|
380
|
+
|
|
381
|
+
const deploymentId = result.data && result.data.deployment_id ? String(result.data.deployment_id) : "";
|
|
382
|
+
if (!deploymentId) {
|
|
383
|
+
return createErrorResult({
|
|
384
|
+
action: "trigger",
|
|
385
|
+
nonBlocking,
|
|
386
|
+
error: "missing deployment_id in response",
|
|
387
|
+
httpStatus: result.httpStatus,
|
|
388
|
+
responseText: result.responseText,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
skipped: false,
|
|
395
|
+
action: "trigger",
|
|
396
|
+
httpStatus: result.httpStatus,
|
|
397
|
+
deploymentId,
|
|
398
|
+
status: String(result.data.status || "created"),
|
|
399
|
+
message: result.data.message || null,
|
|
400
|
+
data: result.data,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function finish(options = {}) {
|
|
405
|
+
const nonBlocking = options.nonBlocking !== undefined ? options.nonBlocking : defaults.nonBlocking;
|
|
406
|
+
const deploymentId = String(options.deploymentId || "");
|
|
407
|
+
if (!deploymentId) {
|
|
408
|
+
return createErrorResult({
|
|
409
|
+
action: "finish",
|
|
410
|
+
nonBlocking,
|
|
411
|
+
error: "missing deploymentId",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const resultValue = String(
|
|
416
|
+
options.result || inferResultFromPipelineStatus(options.pipelineStatus || options.jobStatus || "success")
|
|
417
|
+
);
|
|
418
|
+
const metricsEndpoint = String(options.metricsEndpoint || defaults.metricsEndpoint || "");
|
|
419
|
+
|
|
420
|
+
const payload = {
|
|
421
|
+
deployment_id: deploymentId,
|
|
422
|
+
result: resultValue,
|
|
423
|
+
};
|
|
424
|
+
if (metricsEndpoint) {
|
|
425
|
+
payload.metrics_endpoint = metricsEndpoint;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const result = await request("/deployments/finish", payload, {
|
|
429
|
+
...options,
|
|
430
|
+
action: "finish",
|
|
431
|
+
nonBlocking,
|
|
432
|
+
});
|
|
433
|
+
if (!result.ok) return result;
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
skipped: false,
|
|
438
|
+
action: "finish",
|
|
439
|
+
httpStatus: result.httpStatus,
|
|
440
|
+
status: String(result.data.status || "accepted"),
|
|
441
|
+
message: result.data.message || null,
|
|
442
|
+
data: result.data,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
trigger,
|
|
448
|
+
finish,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function triggerDeployment(options = {}) {
|
|
453
|
+
const client = createCIClient(options);
|
|
454
|
+
return client.trigger(options);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function finishDeployment(options = {}) {
|
|
458
|
+
const client = createCIClient(options);
|
|
459
|
+
return client.finish(options);
|
|
460
|
+
}
|
|
461
|
+
|
|
203
462
|
module.exports = {
|
|
204
463
|
init,
|
|
205
464
|
metrics,
|
|
206
465
|
getMetricsSnapshot,
|
|
466
|
+
createCIClient,
|
|
467
|
+
triggerDeployment,
|
|
468
|
+
finishDeployment,
|
|
469
|
+
buildCiIdempotencyKey,
|
|
470
|
+
inferResultFromPipelineStatus,
|
|
471
|
+
ci: {
|
|
472
|
+
createClient: createCIClient,
|
|
473
|
+
trigger: triggerDeployment,
|
|
474
|
+
finish: finishDeployment,
|
|
475
|
+
buildIdempotencyKey: buildCiIdempotencyKey,
|
|
476
|
+
inferResultFromPipelineStatus,
|
|
477
|
+
},
|
|
207
478
|
};
|
package/package.json
CHANGED
package/scripts/smoke.js
CHANGED
|
@@ -42,7 +42,7 @@ function runMiddleware(mw, req, res) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
async function testNoHmac() {
|
|
45
|
-
sdk.init({
|
|
45
|
+
sdk.init({ endpoint: "/seqpulse-metrics", hmacEnabled: false });
|
|
46
46
|
const mw = sdk.metrics();
|
|
47
47
|
|
|
48
48
|
const req1 = mockReq({ path: "/health" });
|
|
@@ -63,7 +63,6 @@ async function testNoHmac() {
|
|
|
63
63
|
async function testHmac() {
|
|
64
64
|
const secret = "local_hmac_secret";
|
|
65
65
|
sdk.init({
|
|
66
|
-
apiKey: "sp_local",
|
|
67
66
|
endpoint: "/seqpulse-metrics",
|
|
68
67
|
hmacEnabled: true,
|
|
69
68
|
hmacSecret: secret,
|
|
@@ -99,9 +98,71 @@ async function testHmac() {
|
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
100
|
|
|
101
|
+
async function testCiClient() {
|
|
102
|
+
const calls = [];
|
|
103
|
+
const transport = async ({ url, body }) => {
|
|
104
|
+
calls.push({ url, body: JSON.parse(body) });
|
|
105
|
+
if (url.endsWith("/deployments/trigger")) {
|
|
106
|
+
return {
|
|
107
|
+
status: 200,
|
|
108
|
+
text: JSON.stringify({
|
|
109
|
+
deployment_id: "00000000-0000-0000-0000-000000000123",
|
|
110
|
+
status: "created",
|
|
111
|
+
message: "Deployment created",
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (url.endsWith("/deployments/finish")) {
|
|
116
|
+
return {
|
|
117
|
+
status: 200,
|
|
118
|
+
text: JSON.stringify({
|
|
119
|
+
status: "accepted",
|
|
120
|
+
message: "Deployment finished",
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { status: 404, text: "" };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const client = sdk.createCIClient({
|
|
128
|
+
baseUrl: "https://api.seqpulse.dev",
|
|
129
|
+
apiKey: "sp_test",
|
|
130
|
+
metricsEndpoint: "https://my-app.example.com/seqpulse-metrics",
|
|
131
|
+
transport,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const triggerResult = await client.trigger({
|
|
135
|
+
idempotencyKey: "smoke-run-1",
|
|
136
|
+
branch: "main",
|
|
137
|
+
env: "prod",
|
|
138
|
+
});
|
|
139
|
+
if (!triggerResult.ok || !triggerResult.deploymentId) {
|
|
140
|
+
throw new Error("CI trigger should return a deployment id");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const finishResult = await client.finish({
|
|
144
|
+
deploymentId: triggerResult.deploymentId,
|
|
145
|
+
pipelineStatus: "success",
|
|
146
|
+
});
|
|
147
|
+
if (!finishResult.ok || finishResult.status !== "accepted") {
|
|
148
|
+
throw new Error("CI finish should be accepted");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (calls.length !== 2) {
|
|
152
|
+
throw new Error("CI smoke should execute trigger + finish calls");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const nonBlockingClient = sdk.createCIClient({ nonBlocking: true, transport });
|
|
156
|
+
const skipped = await nonBlockingClient.trigger({ idempotencyKey: "x" });
|
|
157
|
+
if (skipped.ok !== false || skipped.skipped !== true) {
|
|
158
|
+
throw new Error("Non-blocking trigger should skip when config is missing");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
102
162
|
async function main() {
|
|
103
163
|
await testNoHmac();
|
|
104
164
|
await testHmac();
|
|
165
|
+
await testCiClient();
|
|
105
166
|
console.log("seqpulse node smoke: OK");
|
|
106
167
|
}
|
|
107
168
|
|