@storewatch/node 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/index.cjs +197 -0
- package/dist/index.d.cts +77 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +169 -0
- package/dist/otel.cjs +35 -0
- package/dist/otel.d.cts +2 -0
- package/dist/otel.d.ts +2 -0
- package/dist/otel.js +10 -0
- package/package.json +62 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Edin Skeja
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @storewatch/node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Storewatch server SDK. Your backend is the source of truth for money: orders,
|
|
4
|
+
payments and refunds sent from here are what revenue is built on.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { createStorewatch, eventIdFor } from "@storewatch/node";
|
|
8
|
+
|
|
9
|
+
const storewatch = createStorewatch({ secretKey: process.env.STOREWATCH_SECRET_KEY!, endpoint: process.env.STOREWATCH_ENDPOINT! });
|
|
10
|
+
|
|
11
|
+
storewatch.track("order_completed", {
|
|
12
|
+
sessionId, // the shopper's browser session, e.g. from cart metadata
|
|
13
|
+
eventId: eventIdFor("order_completed", order.id), // retries count once
|
|
14
|
+
properties: { orderId: order.id, total: { amount: 129900, currency: "SEK" }, items: [] },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
await storewatch.flush(); // before a serverless function returns
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`track` never throws and never waits for the network. Events are validated
|
|
21
|
+
locally, so mistakes show up in your logs. For trace links, pass
|
|
22
|
+
`traceContext: otelTraceContext` from `@storewatch/node/otel`. ESM and CommonJS.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
24
|
+
StorewatchError: () => StorewatchError,
|
|
25
|
+
createStorewatch: () => createStorewatch,
|
|
26
|
+
eventIdFor: () => eventIdFor
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
var import_node_crypto = require("crypto");
|
|
30
|
+
var import_schema = require("@storewatch/schema");
|
|
31
|
+
var import_constants = require("@storewatch/schema/constants");
|
|
32
|
+
var SDK_VERSION = "0.2.0";
|
|
33
|
+
var StorewatchError = class extends Error {
|
|
34
|
+
kind;
|
|
35
|
+
details;
|
|
36
|
+
// No parameter properties: the source stays erasable TypeScript (Node's type stripping can run it).
|
|
37
|
+
constructor(kind, message, details = {}) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "StorewatchError";
|
|
40
|
+
this.kind = kind;
|
|
41
|
+
this.details = details;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
function eventIdFor(...parts) {
|
|
45
|
+
const hex = (0, import_node_crypto.createHash)("sha256").update(parts.join("")).digest("hex");
|
|
46
|
+
const variant = (parseInt(hex.slice(16, 17), 16) & 3 | 8).toString(16);
|
|
47
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
48
|
+
}
|
|
49
|
+
function defaultOnError() {
|
|
50
|
+
let last = 0;
|
|
51
|
+
let suppressed = 0;
|
|
52
|
+
return (error) => {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
if (now - last < 6e4) {
|
|
55
|
+
suppressed++;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
last = now;
|
|
59
|
+
const extra = suppressed > 0 ? ` (${suppressed} similar messages suppressed)` : "";
|
|
60
|
+
suppressed = 0;
|
|
61
|
+
console.warn(`[storewatch] ${error.message}${extra}`);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
65
|
+
function createStorewatch(options) {
|
|
66
|
+
const { endpoint } = options;
|
|
67
|
+
const flushIntervalMs = options.flushIntervalMs ?? 1e3;
|
|
68
|
+
const flushAt = Math.min(options.flushAt ?? 50, import_constants.LIMITS.batchMaxEvents);
|
|
69
|
+
const maxQueue = options.maxQueue ?? 5e3;
|
|
70
|
+
const maxAttempts = options.maxAttempts ?? 6;
|
|
71
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
72
|
+
const report = options.onError ?? defaultOnError();
|
|
73
|
+
const doFetch = options.fetch ?? fetch;
|
|
74
|
+
if (!options.secretKey?.startsWith("sw_sk_")) {
|
|
75
|
+
report(new StorewatchError("invalid_event", "secretKey must be a Storewatch secret key (sw_sk_\u2026); events will not be sent."));
|
|
76
|
+
}
|
|
77
|
+
let queue = [];
|
|
78
|
+
let timer;
|
|
79
|
+
let draining = null;
|
|
80
|
+
let stopped = false;
|
|
81
|
+
const safeReport = (error) => {
|
|
82
|
+
try {
|
|
83
|
+
report(error);
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
function ensureTimer() {
|
|
88
|
+
if (timer || stopped) return;
|
|
89
|
+
timer = setInterval(() => void flush(), flushIntervalMs);
|
|
90
|
+
timer.unref?.();
|
|
91
|
+
}
|
|
92
|
+
async function send(batch) {
|
|
93
|
+
for (let attempt = 1; ; attempt++) {
|
|
94
|
+
let status = 0;
|
|
95
|
+
try {
|
|
96
|
+
const res = await doFetch(endpoint, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "content-type": "application/json", "x-storewatch-key": options.secretKey },
|
|
99
|
+
body: JSON.stringify({ sentAt: (/* @__PURE__ */ new Date()).toISOString(), batch }),
|
|
100
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
101
|
+
});
|
|
102
|
+
status = res.status;
|
|
103
|
+
if (status === 202) {
|
|
104
|
+
const body = await res.json().catch(() => null);
|
|
105
|
+
for (const r of body?.rejected ?? []) {
|
|
106
|
+
safeReport(new StorewatchError("rejected", `ingest rejected event ${r.eventId ?? "?"}: ${r.issues.map((i) => `${i.path} ${i.message}`).join("; ")}`, r));
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (status !== 429 && status < 500) {
|
|
111
|
+
safeReport(new StorewatchError("rejected", `ingest refused the batch (${status}); ${batch.length} events dropped`, { status }));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
if (attempt >= maxAttempts || stopped) {
|
|
117
|
+
safeReport(new StorewatchError("delivery_failed", `could not deliver ${batch.length} events after ${attempt} attempts${status ? ` (last status ${status})` : ""}`, { status }));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
await sleep(Math.min(3e4, 500 * 2 ** (attempt - 1)) * (0.75 + Math.random() * 0.5));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function drain() {
|
|
124
|
+
while (queue.length > 0) {
|
|
125
|
+
const batch = queue.splice(0, flushAt);
|
|
126
|
+
await send(batch);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function flush() {
|
|
130
|
+
draining ??= drain().finally(() => {
|
|
131
|
+
draining = null;
|
|
132
|
+
});
|
|
133
|
+
return draining;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
track(name, o) {
|
|
137
|
+
try {
|
|
138
|
+
if (stopped) return;
|
|
139
|
+
const trace = o.context?.trace === void 0 ? safeTrace(options.traceContext) : void 0;
|
|
140
|
+
const event = {
|
|
141
|
+
eventId: o.eventId ?? (0, import_node_crypto.randomUUID)(),
|
|
142
|
+
name,
|
|
143
|
+
timestamp: o.timestamp === void 0 ? (/* @__PURE__ */ new Date()).toISOString() : new Date(o.timestamp).toISOString(),
|
|
144
|
+
sessionId: o.sessionId,
|
|
145
|
+
...o.anonymousId ? { anonymousId: o.anonymousId } : {},
|
|
146
|
+
...o.customerId ? { customerId: o.customerId } : {},
|
|
147
|
+
properties: o.properties,
|
|
148
|
+
context: { ...trace ? { trace } : {}, ...o.context, library: { name: "@storewatch/node", version: SDK_VERSION } }
|
|
149
|
+
};
|
|
150
|
+
const result = (0, import_schema.validateEvent)(event);
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
safeReport(
|
|
153
|
+
new StorewatchError("invalid_event", `${name} not sent: ${result.issues.map((i) => `${i.path || "event"} ${i.message}`).join("; ")}`, {
|
|
154
|
+
eventId: event.eventId,
|
|
155
|
+
issues: result.issues
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
queue.push(event);
|
|
161
|
+
if (queue.length > maxQueue) {
|
|
162
|
+
queue.splice(0, queue.length - maxQueue);
|
|
163
|
+
safeReport(new StorewatchError("queue_full", `queue full (${maxQueue}); dropped the oldest events`));
|
|
164
|
+
}
|
|
165
|
+
ensureTimer();
|
|
166
|
+
if (queue.length >= flushAt) void flush();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
safeReport(new StorewatchError("invalid_event", `${String(name)} not sent: ${error instanceof Error ? error.message : String(error)}`));
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
flush,
|
|
172
|
+
async shutdown(waitMs = 5e3) {
|
|
173
|
+
if (timer) clearInterval(timer);
|
|
174
|
+
timer = void 0;
|
|
175
|
+
await Promise.race([flush(), sleep(waitMs)]);
|
|
176
|
+
stopped = true;
|
|
177
|
+
},
|
|
178
|
+
get pending() {
|
|
179
|
+
return queue.length;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
function safeTrace(get) {
|
|
183
|
+
try {
|
|
184
|
+
const t = get?.();
|
|
185
|
+
return t && /^[0-9a-f]{32}$/.test(t.traceId) && t.traceId !== "0".repeat(32) ? t : void 0;
|
|
186
|
+
} catch {
|
|
187
|
+
return void 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
192
|
+
0 && (module.exports = {
|
|
193
|
+
SDK_VERSION,
|
|
194
|
+
StorewatchError,
|
|
195
|
+
createStorewatch,
|
|
196
|
+
eventIdFor
|
|
197
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CanonicalEventName, CanonicalProperties, EventContext } from "@storewatch/schema";
|
|
2
|
+
import { type ValidationIssue } from "@storewatch/schema";
|
|
3
|
+
export declare const SDK_VERSION = "0.2.0";
|
|
4
|
+
/** Canonical events get their typed properties; anything else is a custom event. */
|
|
5
|
+
export type EventProperties<N extends string> = N extends CanonicalEventName ? CanonicalProperties[N] : Record<string, unknown>;
|
|
6
|
+
export type TrackOptions<N extends string> = {
|
|
7
|
+
/**
|
|
8
|
+
* The shopper's browser session, so the server event joins their live
|
|
9
|
+
* journey (e.g. from cart metadata). Without one, the event still counts
|
|
10
|
+
* (an order is an order) but isn't tied to a visitor.
|
|
11
|
+
*/
|
|
12
|
+
sessionId: string;
|
|
13
|
+
properties: EventProperties<N>;
|
|
14
|
+
/**
|
|
15
|
+
* Make this deterministic for anything that can be retried (see
|
|
16
|
+
* `eventIdFor`): ingest drops events whose id it has already seen.
|
|
17
|
+
*/
|
|
18
|
+
eventId?: string;
|
|
19
|
+
/** When it happened. Default: now. */
|
|
20
|
+
timestamp?: Date | string;
|
|
21
|
+
/** The merchant's own customer id. Hashed per project at ingest; never stored raw. */
|
|
22
|
+
customerId?: string;
|
|
23
|
+
anonymousId?: string;
|
|
24
|
+
context?: Omit<EventContext, "library">;
|
|
25
|
+
};
|
|
26
|
+
export type StorewatchErrorKind = "invalid_event" | "rejected" | "delivery_failed" | "queue_full";
|
|
27
|
+
export declare class StorewatchError extends Error {
|
|
28
|
+
readonly kind: StorewatchErrorKind;
|
|
29
|
+
readonly details: {
|
|
30
|
+
eventId?: string;
|
|
31
|
+
issues?: ValidationIssue[];
|
|
32
|
+
status?: number;
|
|
33
|
+
};
|
|
34
|
+
constructor(kind: StorewatchErrorKind, message: string, details?: StorewatchError["details"]);
|
|
35
|
+
}
|
|
36
|
+
export type TraceContext = {
|
|
37
|
+
traceId: string;
|
|
38
|
+
spanId?: string;
|
|
39
|
+
};
|
|
40
|
+
export type StorewatchOptions = {
|
|
41
|
+
/** Secret key (sw_sk_…). Server-side only, never in a browser bundle. */
|
|
42
|
+
secretKey: string;
|
|
43
|
+
/** Your ingest URL, e.g. https://ingest.example.com/v1/batch. */
|
|
44
|
+
endpoint: string;
|
|
45
|
+
/** Batches are sent at least this often. Default 1000 ms. */
|
|
46
|
+
flushIntervalMs?: number;
|
|
47
|
+
/** …or as soon as this many events are queued. Default 50. */
|
|
48
|
+
flushAt?: number;
|
|
49
|
+
/** Events kept while ingest is unreachable; the oldest are dropped beyond it. Default 5000. */
|
|
50
|
+
maxQueue?: number;
|
|
51
|
+
/** Delivery attempts per batch before it is dropped. Default 6 (about a minute of backoff). */
|
|
52
|
+
maxAttempts?: number;
|
|
53
|
+
/** Per-request timeout. Default 10 s. */
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
/** The active trace, attached to every event (see `@storewatch/node/otel`). */
|
|
56
|
+
traceContext?: () => TraceContext | undefined;
|
|
57
|
+
/** Delivery and validation problems. Default: a rate-limited console.warn. */
|
|
58
|
+
onError?: (error: StorewatchError) => void;
|
|
59
|
+
/** For tests and unusual runtimes. */
|
|
60
|
+
fetch?: typeof fetch;
|
|
61
|
+
};
|
|
62
|
+
export type Storewatch = {
|
|
63
|
+
track<N extends string>(name: N, options: TrackOptions<N>): void;
|
|
64
|
+
/** Sends everything queued; resolves when done (or given up). */
|
|
65
|
+
flush(): Promise<void>;
|
|
66
|
+
/** Flushes, then stops the timer. Waits at most `timeoutMs` (default 5 s). */
|
|
67
|
+
shutdown(timeoutMs?: number): Promise<void>;
|
|
68
|
+
/** Events waiting to be sent. */
|
|
69
|
+
readonly pending: number;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A stable event id from parts that identify the fact, e.g.
|
|
73
|
+
* `eventIdFor("order_completed", orderId)`. Re-running the same handler then
|
|
74
|
+
* produces the same id, and ingest keeps the event once.
|
|
75
|
+
*/
|
|
76
|
+
export declare function eventIdFor(...parts: string[]): string;
|
|
77
|
+
export declare function createStorewatch(options: StorewatchOptions): Storewatch;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { CanonicalEventName, CanonicalProperties, EventContext } from "@storewatch/schema";
|
|
2
|
+
import { type ValidationIssue } from "@storewatch/schema";
|
|
3
|
+
export declare const SDK_VERSION = "0.2.0";
|
|
4
|
+
/** Canonical events get their typed properties; anything else is a custom event. */
|
|
5
|
+
export type EventProperties<N extends string> = N extends CanonicalEventName ? CanonicalProperties[N] : Record<string, unknown>;
|
|
6
|
+
export type TrackOptions<N extends string> = {
|
|
7
|
+
/**
|
|
8
|
+
* The shopper's browser session, so the server event joins their live
|
|
9
|
+
* journey (e.g. from cart metadata). Without one, the event still counts
|
|
10
|
+
* (an order is an order) but isn't tied to a visitor.
|
|
11
|
+
*/
|
|
12
|
+
sessionId: string;
|
|
13
|
+
properties: EventProperties<N>;
|
|
14
|
+
/**
|
|
15
|
+
* Make this deterministic for anything that can be retried (see
|
|
16
|
+
* `eventIdFor`): ingest drops events whose id it has already seen.
|
|
17
|
+
*/
|
|
18
|
+
eventId?: string;
|
|
19
|
+
/** When it happened. Default: now. */
|
|
20
|
+
timestamp?: Date | string;
|
|
21
|
+
/** The merchant's own customer id. Hashed per project at ingest; never stored raw. */
|
|
22
|
+
customerId?: string;
|
|
23
|
+
anonymousId?: string;
|
|
24
|
+
context?: Omit<EventContext, "library">;
|
|
25
|
+
};
|
|
26
|
+
export type StorewatchErrorKind = "invalid_event" | "rejected" | "delivery_failed" | "queue_full";
|
|
27
|
+
export declare class StorewatchError extends Error {
|
|
28
|
+
readonly kind: StorewatchErrorKind;
|
|
29
|
+
readonly details: {
|
|
30
|
+
eventId?: string;
|
|
31
|
+
issues?: ValidationIssue[];
|
|
32
|
+
status?: number;
|
|
33
|
+
};
|
|
34
|
+
constructor(kind: StorewatchErrorKind, message: string, details?: StorewatchError["details"]);
|
|
35
|
+
}
|
|
36
|
+
export type TraceContext = {
|
|
37
|
+
traceId: string;
|
|
38
|
+
spanId?: string;
|
|
39
|
+
};
|
|
40
|
+
export type StorewatchOptions = {
|
|
41
|
+
/** Secret key (sw_sk_…). Server-side only, never in a browser bundle. */
|
|
42
|
+
secretKey: string;
|
|
43
|
+
/** Your ingest URL, e.g. https://ingest.example.com/v1/batch. */
|
|
44
|
+
endpoint: string;
|
|
45
|
+
/** Batches are sent at least this often. Default 1000 ms. */
|
|
46
|
+
flushIntervalMs?: number;
|
|
47
|
+
/** …or as soon as this many events are queued. Default 50. */
|
|
48
|
+
flushAt?: number;
|
|
49
|
+
/** Events kept while ingest is unreachable; the oldest are dropped beyond it. Default 5000. */
|
|
50
|
+
maxQueue?: number;
|
|
51
|
+
/** Delivery attempts per batch before it is dropped. Default 6 (about a minute of backoff). */
|
|
52
|
+
maxAttempts?: number;
|
|
53
|
+
/** Per-request timeout. Default 10 s. */
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
/** The active trace, attached to every event (see `@storewatch/node/otel`). */
|
|
56
|
+
traceContext?: () => TraceContext | undefined;
|
|
57
|
+
/** Delivery and validation problems. Default: a rate-limited console.warn. */
|
|
58
|
+
onError?: (error: StorewatchError) => void;
|
|
59
|
+
/** For tests and unusual runtimes. */
|
|
60
|
+
fetch?: typeof fetch;
|
|
61
|
+
};
|
|
62
|
+
export type Storewatch = {
|
|
63
|
+
track<N extends string>(name: N, options: TrackOptions<N>): void;
|
|
64
|
+
/** Sends everything queued; resolves when done (or given up). */
|
|
65
|
+
flush(): Promise<void>;
|
|
66
|
+
/** Flushes, then stops the timer. Waits at most `timeoutMs` (default 5 s). */
|
|
67
|
+
shutdown(timeoutMs?: number): Promise<void>;
|
|
68
|
+
/** Events waiting to be sent. */
|
|
69
|
+
readonly pending: number;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A stable event id from parts that identify the fact, e.g.
|
|
73
|
+
* `eventIdFor("order_completed", orderId)`. Re-running the same handler then
|
|
74
|
+
* produces the same id, and ingest keeps the event once.
|
|
75
|
+
*/
|
|
76
|
+
export declare function eventIdFor(...parts: string[]): string;
|
|
77
|
+
export declare function createStorewatch(options: StorewatchOptions): Storewatch;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
|
+
import { validateEvent } from "@storewatch/schema";
|
|
4
|
+
import { LIMITS } from "@storewatch/schema/constants";
|
|
5
|
+
var SDK_VERSION = "0.2.0";
|
|
6
|
+
var StorewatchError = class extends Error {
|
|
7
|
+
kind;
|
|
8
|
+
details;
|
|
9
|
+
// No parameter properties: the source stays erasable TypeScript (Node's type stripping can run it).
|
|
10
|
+
constructor(kind, message, details = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "StorewatchError";
|
|
13
|
+
this.kind = kind;
|
|
14
|
+
this.details = details;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
function eventIdFor(...parts) {
|
|
18
|
+
const hex = createHash("sha256").update(parts.join("")).digest("hex");
|
|
19
|
+
const variant = (parseInt(hex.slice(16, 17), 16) & 3 | 8).toString(16);
|
|
20
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
21
|
+
}
|
|
22
|
+
function defaultOnError() {
|
|
23
|
+
let last = 0;
|
|
24
|
+
let suppressed = 0;
|
|
25
|
+
return (error) => {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
if (now - last < 6e4) {
|
|
28
|
+
suppressed++;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
last = now;
|
|
32
|
+
const extra = suppressed > 0 ? ` (${suppressed} similar messages suppressed)` : "";
|
|
33
|
+
suppressed = 0;
|
|
34
|
+
console.warn(`[storewatch] ${error.message}${extra}`);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
38
|
+
function createStorewatch(options) {
|
|
39
|
+
const { endpoint } = options;
|
|
40
|
+
const flushIntervalMs = options.flushIntervalMs ?? 1e3;
|
|
41
|
+
const flushAt = Math.min(options.flushAt ?? 50, LIMITS.batchMaxEvents);
|
|
42
|
+
const maxQueue = options.maxQueue ?? 5e3;
|
|
43
|
+
const maxAttempts = options.maxAttempts ?? 6;
|
|
44
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
45
|
+
const report = options.onError ?? defaultOnError();
|
|
46
|
+
const doFetch = options.fetch ?? fetch;
|
|
47
|
+
if (!options.secretKey?.startsWith("sw_sk_")) {
|
|
48
|
+
report(new StorewatchError("invalid_event", "secretKey must be a Storewatch secret key (sw_sk_\u2026); events will not be sent."));
|
|
49
|
+
}
|
|
50
|
+
let queue = [];
|
|
51
|
+
let timer;
|
|
52
|
+
let draining = null;
|
|
53
|
+
let stopped = false;
|
|
54
|
+
const safeReport = (error) => {
|
|
55
|
+
try {
|
|
56
|
+
report(error);
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
function ensureTimer() {
|
|
61
|
+
if (timer || stopped) return;
|
|
62
|
+
timer = setInterval(() => void flush(), flushIntervalMs);
|
|
63
|
+
timer.unref?.();
|
|
64
|
+
}
|
|
65
|
+
async function send(batch) {
|
|
66
|
+
for (let attempt = 1; ; attempt++) {
|
|
67
|
+
let status = 0;
|
|
68
|
+
try {
|
|
69
|
+
const res = await doFetch(endpoint, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "content-type": "application/json", "x-storewatch-key": options.secretKey },
|
|
72
|
+
body: JSON.stringify({ sentAt: (/* @__PURE__ */ new Date()).toISOString(), batch }),
|
|
73
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
74
|
+
});
|
|
75
|
+
status = res.status;
|
|
76
|
+
if (status === 202) {
|
|
77
|
+
const body = await res.json().catch(() => null);
|
|
78
|
+
for (const r of body?.rejected ?? []) {
|
|
79
|
+
safeReport(new StorewatchError("rejected", `ingest rejected event ${r.eventId ?? "?"}: ${r.issues.map((i) => `${i.path} ${i.message}`).join("; ")}`, r));
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (status !== 429 && status < 500) {
|
|
84
|
+
safeReport(new StorewatchError("rejected", `ingest refused the batch (${status}); ${batch.length} events dropped`, { status }));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
if (attempt >= maxAttempts || stopped) {
|
|
90
|
+
safeReport(new StorewatchError("delivery_failed", `could not deliver ${batch.length} events after ${attempt} attempts${status ? ` (last status ${status})` : ""}`, { status }));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await sleep(Math.min(3e4, 500 * 2 ** (attempt - 1)) * (0.75 + Math.random() * 0.5));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function drain() {
|
|
97
|
+
while (queue.length > 0) {
|
|
98
|
+
const batch = queue.splice(0, flushAt);
|
|
99
|
+
await send(batch);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function flush() {
|
|
103
|
+
draining ??= drain().finally(() => {
|
|
104
|
+
draining = null;
|
|
105
|
+
});
|
|
106
|
+
return draining;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
track(name, o) {
|
|
110
|
+
try {
|
|
111
|
+
if (stopped) return;
|
|
112
|
+
const trace = o.context?.trace === void 0 ? safeTrace(options.traceContext) : void 0;
|
|
113
|
+
const event = {
|
|
114
|
+
eventId: o.eventId ?? randomUUID(),
|
|
115
|
+
name,
|
|
116
|
+
timestamp: o.timestamp === void 0 ? (/* @__PURE__ */ new Date()).toISOString() : new Date(o.timestamp).toISOString(),
|
|
117
|
+
sessionId: o.sessionId,
|
|
118
|
+
...o.anonymousId ? { anonymousId: o.anonymousId } : {},
|
|
119
|
+
...o.customerId ? { customerId: o.customerId } : {},
|
|
120
|
+
properties: o.properties,
|
|
121
|
+
context: { ...trace ? { trace } : {}, ...o.context, library: { name: "@storewatch/node", version: SDK_VERSION } }
|
|
122
|
+
};
|
|
123
|
+
const result = validateEvent(event);
|
|
124
|
+
if (!result.ok) {
|
|
125
|
+
safeReport(
|
|
126
|
+
new StorewatchError("invalid_event", `${name} not sent: ${result.issues.map((i) => `${i.path || "event"} ${i.message}`).join("; ")}`, {
|
|
127
|
+
eventId: event.eventId,
|
|
128
|
+
issues: result.issues
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
queue.push(event);
|
|
134
|
+
if (queue.length > maxQueue) {
|
|
135
|
+
queue.splice(0, queue.length - maxQueue);
|
|
136
|
+
safeReport(new StorewatchError("queue_full", `queue full (${maxQueue}); dropped the oldest events`));
|
|
137
|
+
}
|
|
138
|
+
ensureTimer();
|
|
139
|
+
if (queue.length >= flushAt) void flush();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
safeReport(new StorewatchError("invalid_event", `${String(name)} not sent: ${error instanceof Error ? error.message : String(error)}`));
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
flush,
|
|
145
|
+
async shutdown(waitMs = 5e3) {
|
|
146
|
+
if (timer) clearInterval(timer);
|
|
147
|
+
timer = void 0;
|
|
148
|
+
await Promise.race([flush(), sleep(waitMs)]);
|
|
149
|
+
stopped = true;
|
|
150
|
+
},
|
|
151
|
+
get pending() {
|
|
152
|
+
return queue.length;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
function safeTrace(get) {
|
|
156
|
+
try {
|
|
157
|
+
const t = get?.();
|
|
158
|
+
return t && /^[0-9a-f]{32}$/.test(t.traceId) && t.traceId !== "0".repeat(32) ? t : void 0;
|
|
159
|
+
} catch {
|
|
160
|
+
return void 0;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export {
|
|
165
|
+
SDK_VERSION,
|
|
166
|
+
StorewatchError,
|
|
167
|
+
createStorewatch,
|
|
168
|
+
eventIdFor
|
|
169
|
+
};
|
package/dist/otel.cjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/otel.ts
|
|
21
|
+
var otel_exports = {};
|
|
22
|
+
__export(otel_exports, {
|
|
23
|
+
otelTraceContext: () => otelTraceContext
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(otel_exports);
|
|
26
|
+
var import_api = require("@opentelemetry/api");
|
|
27
|
+
function otelTraceContext() {
|
|
28
|
+
const ctx = import_api.trace.getActiveSpan()?.spanContext();
|
|
29
|
+
if (!ctx || !(0, import_api.isSpanContextValid)(ctx)) return void 0;
|
|
30
|
+
return { traceId: ctx.traceId, spanId: ctx.spanId };
|
|
31
|
+
}
|
|
32
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
33
|
+
0 && (module.exports = {
|
|
34
|
+
otelTraceContext
|
|
35
|
+
});
|
package/dist/otel.d.cts
ADDED
package/dist/otel.d.ts
ADDED
package/dist/otel.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/otel.ts
|
|
2
|
+
import { isSpanContextValid, trace } from "@opentelemetry/api";
|
|
3
|
+
function otelTraceContext() {
|
|
4
|
+
const ctx = trace.getActiveSpan()?.spanContext();
|
|
5
|
+
if (!ctx || !isSpanContextValid(ctx)) return void 0;
|
|
6
|
+
return { traceId: ctx.traceId, spanId: ctx.spanId };
|
|
7
|
+
}
|
|
8
|
+
export {
|
|
9
|
+
otelTraceContext
|
|
10
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@storewatch/node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Storewatch server SDK: authoritative orders and payments from your backend.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"require": {
|
|
15
|
+
"types": "./dist/index.d.cts",
|
|
16
|
+
"default": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"./otel": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/otel.d.ts",
|
|
22
|
+
"default": "./dist/otel.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/otel.d.cts",
|
|
26
|
+
"default": "./dist/otel.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.17"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@storewatch/schema": "0.2.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@opentelemetry/api": "^1.4.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"@opentelemetry/api": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@opentelemetry/api": "^1.9.0",
|
|
52
|
+
"tsup": "^9.0.0",
|
|
53
|
+
"@storewatch/config": "0.0.0"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"storewatch",
|
|
57
|
+
"analytics",
|
|
58
|
+
"ecommerce"
|
|
59
|
+
],
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "rm -rf dist && tsup && tsc -p tsconfig.build.json && node ../../scripts/dual-dts.mjs dist",
|
|
62
|
+
"typecheck": "tsc -p .",
|
|
63
|
+
"test": "vitest run"
|
|
64
|
+
}
|
|
6
65
|
}
|