seqpulse 0.2.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 CHANGED
@@ -1,10 +1,9 @@
1
1
  # seqpulse (Node.js SDK)
2
2
 
3
- SeqPulse SDK for:
3
+ SeqPulse Node SDK couvre deux usages:
4
4
 
5
- - HTTP metrics instrumentation
6
- - metrics endpoint exposure
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 usage
16
+ ## Runtime (Express)
18
17
 
19
18
  ```js
20
19
  const express = require("express");
@@ -37,8 +36,6 @@ app.listen(3000, () => {
37
36
  });
38
37
  ```
39
38
 
40
- ## Returned payload
41
-
42
39
  `GET /seqpulse-metrics` returns:
43
40
 
44
41
  ```json
@@ -53,11 +50,50 @@ app.listen(3000, () => {
53
50
  }
54
51
  ```
55
52
 
56
- ## Notes
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:
57
93
 
58
- - This SDK does not manage CI/CD trigger/finish workflow.
59
- - `apiKey` is optional and not used directly in metrics payload or HMAC validation.
60
- - Metrics sampling window is fixed to 60 seconds (not configurable in SDK init).
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
@@ -15,10 +15,96 @@ export type SeqPulseMetrics = {
15
15
  memory_usage: number
16
16
  }
17
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
+
18
90
  export type SeqPulseInstance = {
19
91
  init(config: SeqPulseInitConfig): void
20
92
  metrics(): (req: any, res: any, next: () => void) => void
21
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
+ }
22
108
  }
23
109
 
24
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 DEFAULT_WINDOW_SECONDS = 60;
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() - state.config.windowSeconds * 1000;
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 / windowSeconds, 3),
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),
@@ -154,10 +158,7 @@ function init(config) {
154
158
 
155
159
  state.config = {
156
160
  apiKey: typeof config.apiKey === "string" ? config.apiKey : "",
157
- endpoint,
158
- // Fixed to backend contract: 60s observation window for SDK sampling.
159
- windowSeconds: DEFAULT_WINDOW_SECONDS,
160
- hmacEnabled,
161
+ endpoint, hmacEnabled,
161
162
  hmacSecret,
162
163
  maxSkewPastSeconds: Number(config.maxSkewPastSeconds || DEFAULT_MAX_SKEW_PAST_SECONDS),
163
164
  maxSkewFutureSeconds: Number(config.maxSkewFutureSeconds || DEFAULT_MAX_SKEW_FUTURE_SECONDS),
@@ -194,8 +195,284 @@ function metrics() {
194
195
  };
195
196
  }
196
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
+
197
462
  module.exports = {
198
463
  init,
199
464
  metrics,
200
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
+ },
201
478
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seqpulse",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "SeqPulse SDK for metrics endpoint instrumentation and HMAC validation",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/scripts/smoke.js CHANGED
@@ -98,9 +98,71 @@ async function testHmac() {
98
98
  }
99
99
  }
100
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
+
101
162
  async function main() {
102
163
  await testNoHmac();
103
164
  await testHmac();
165
+ await testCiClient();
104
166
  console.log("seqpulse node smoke: OK");
105
167
  }
106
168