@strada.sh/light 0.1.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.
@@ -0,0 +1,26 @@
1
+ /** Subset of the full SDK attribute keys. Values must match `@strada.sh/sdk` ATTR. */
2
+ export declare const ATTR: {
3
+ readonly "service.name": "service.name";
4
+ readonly "service.version": "service.version";
5
+ readonly "deployment.environment.name": "deployment.environment.name";
6
+ readonly "deployment.id": "deployment.id";
7
+ readonly "vcs.ref.head.revision": "vcs.ref.head.revision";
8
+ readonly "vcs.ref.head.name": "vcs.ref.head.name";
9
+ readonly "event.name": "event.name";
10
+ readonly "user.id": "user.id";
11
+ readonly "user.email": "user.email";
12
+ readonly "user.name": "user.name";
13
+ readonly "user.full_name": "user.full_name";
14
+ readonly "user.hash": "user.hash";
15
+ readonly "user.image": "user.image";
16
+ readonly "organization.id": "organization.id";
17
+ readonly "organization.name": "organization.name";
18
+ readonly "strada.user.identify": "strada.user.identify";
19
+ readonly "exception.type": "exception.type";
20
+ readonly "exception.message": "exception.message";
21
+ readonly "exception.stacktrace": "exception.stacktrace";
22
+ readonly "exception.mechanism.type": "exception.mechanism.type";
23
+ readonly "exception.mechanism.handled": "exception.mechanism.handled";
24
+ readonly "exception.fingerprint": "exception.fingerprint";
25
+ };
26
+ //# sourceMappingURL=attrs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attrs.d.ts","sourceRoot":"","sources":["../src/attrs.ts"],"names":[],"mappings":"AAAA,sFAAsF;AACtF,eAAO,MAAM,IAAI;;;;;;;;;;;;;;;;;;;;;;;CAuBP,CAAC"}
package/dist/attrs.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Subset of the full SDK attribute keys. Values must match `@strada.sh/sdk` ATTR. */
2
+ export const ATTR = {
3
+ "service.name": "service.name",
4
+ "service.version": "service.version",
5
+ "deployment.environment.name": "deployment.environment.name",
6
+ "deployment.id": "deployment.id",
7
+ "vcs.ref.head.revision": "vcs.ref.head.revision",
8
+ "vcs.ref.head.name": "vcs.ref.head.name",
9
+ "event.name": "event.name",
10
+ "user.id": "user.id",
11
+ "user.email": "user.email",
12
+ "user.name": "user.name",
13
+ "user.full_name": "user.full_name",
14
+ "user.hash": "user.hash",
15
+ "user.image": "user.image",
16
+ "organization.id": "organization.id",
17
+ "organization.name": "organization.name",
18
+ "strada.user.identify": "strada.user.identify",
19
+ "exception.type": "exception.type",
20
+ "exception.message": "exception.message",
21
+ "exception.stacktrace": "exception.stacktrace",
22
+ "exception.mechanism.type": "exception.mechanism.type",
23
+ "exception.mechanism.handled": "exception.mechanism.handled",
24
+ "exception.fingerprint": "exception.fingerprint",
25
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `@strada.sh/light`: a zero-dependency, drop-in subset of `@strada.sh/sdk`.
3
+ *
4
+ * Switch by changing the import path. Same function names, same option
5
+ * names, same `otel_logs` rows (`event.name`, `custom.*`, `exception.*`,
6
+ * `strada.user.identify`), so every Strada query works unchanged. What the
7
+ * light build does not support is not exported or not accepted, so a switch
8
+ * that relies on it fails at compile time instead of silently dropping data.
9
+ * `index.test.ts` type-checks this file against the full SDK to keep both in
10
+ * sync.
11
+ *
12
+ * It builds OTLP JSON log records by hand and POSTs them with `fetch` to
13
+ * `/v1/logs`. `initStrada()` installs nothing global: no OTel providers, no
14
+ * process handlers, no resource detectors (no hostname, OS username, or
15
+ * command args). Only what you pass is sent. The flush timer is unref'd, so
16
+ * call `flush()` before a short-lived process exits.
17
+ *
18
+ * Every function follows "telemetry never throws": failures are returned as
19
+ * values and logged once with console.warn.
20
+ */
21
+ export interface StradaOptions {
22
+ /** Strada project identifier. Blank disables sending. */
23
+ projectId: string;
24
+ /** service.name resource attribute */
25
+ service: string;
26
+ /** Kill switch. `false` makes every call a no-op. Defaults to true, false when import.meta.hot is set. */
27
+ enabled?: boolean;
28
+ /** Override the ingest endpoint. Defaults to https://{projectId}-ingest.strada.sh */
29
+ endpoint?: string;
30
+ /** Server-side ingest token. Omit in code shipped to users (npm CLIs, browsers). */
31
+ token?: string;
32
+ /** service.version resource attribute */
33
+ version?: string;
34
+ /** deployment.environment.name resource attribute */
35
+ environment?: string;
36
+ /** vcs.ref.head.revision resource attribute */
37
+ releaseCommit?: string;
38
+ /** vcs.ref.head.name resource attribute */
39
+ releaseBranch?: string;
40
+ /** deployment.id resource attribute. Defaults to releaseCommit. */
41
+ deploymentId?: string;
42
+ /** Current user id, sent as user.id on every event and error. */
43
+ userId?: string | (() => string | undefined);
44
+ /** Same shape as the full SDK. Only log batching applies here. */
45
+ telemetry?: {
46
+ logs?: {
47
+ scheduledDelayMillis?: number;
48
+ maxExportBatchSize?: number;
49
+ };
50
+ };
51
+ }
52
+ export interface StradaUserIdentity {
53
+ /** Stable application user id. */
54
+ id: string;
55
+ /** User email. PII, sent only through identifyUser profile events. */
56
+ email?: string;
57
+ /** Display name or username. */
58
+ name?: string;
59
+ /** Full human-readable name. */
60
+ fullName?: string;
61
+ /** Stable anonymized user hash when raw ids are sensitive. */
62
+ hash?: string;
63
+ /** Profile image URL. */
64
+ image?: string;
65
+ /** Product/account organization id for this user profile. */
66
+ organizationId?: string;
67
+ /** Product/account organization name for this user profile. */
68
+ organizationName?: string;
69
+ /** Additional low-cardinality profile attributes. */
70
+ attributes?: Record<string, string>;
71
+ }
72
+ export interface CaptureExceptionOptions {
73
+ /** Was this error caught by user code (true) or a global handler (false)? */
74
+ handled?: boolean;
75
+ /** How the exception was captured, e.g. onerror or unhandledrejection */
76
+ mechanism?: string;
77
+ /** Extra tags attached to the error */
78
+ tags?: Record<string, string>;
79
+ /** Custom fingerprint override for issue grouping. */
80
+ fingerprint?: string[];
81
+ }
82
+ type AttributeValue = string | number | boolean;
83
+ export declare function initStrada(options: StradaOptions): Error | undefined;
84
+ /** Product analytics event. Properties are stored as `custom.*` attributes. */
85
+ export declare function track(name: string, properties?: Record<string, AttributeValue>): Error | undefined;
86
+ /** Full profile snapshot for `otel_users`. Call from trusted code with every field you want to keep. */
87
+ export declare function identifyUser(user: StradaUserIdentity): Error | undefined;
88
+ /** Tags merged into every captureException() call. */
89
+ export declare function setTags(next: Record<string, string>): void;
90
+ /**
91
+ * Report a handled error as an issue. No ignoreErrors, denyUrls, or
92
+ * beforeSend in the light build; KnownError instances are skipped like in
93
+ * the full SDK.
94
+ */
95
+ export declare function captureException(error: unknown, opts?: CaptureExceptionOptions): Error | undefined;
96
+ export declare function flush(): Promise<Error | undefined>;
97
+ export declare function shutdown(): Promise<Error | undefined>;
98
+ export {};
99
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `@strada.sh/light`: a zero-dependency, drop-in subset of `@strada.sh/sdk`.
3
+ *
4
+ * Switch by changing the import path. Same function names, same option
5
+ * names, same `otel_logs` rows (`event.name`, `custom.*`, `exception.*`,
6
+ * `strada.user.identify`), so every Strada query works unchanged. What the
7
+ * light build does not support is not exported or not accepted, so a switch
8
+ * that relies on it fails at compile time instead of silently dropping data.
9
+ * `index.test.ts` type-checks this file against the full SDK to keep both in
10
+ * sync.
11
+ *
12
+ * It builds OTLP JSON log records by hand and POSTs them with `fetch` to
13
+ * `/v1/logs`. `initStrada()` installs nothing global: no OTel providers, no
14
+ * process handlers, no resource detectors (no hostname, OS username, or
15
+ * command args). Only what you pass is sent. The flush timer is unref'd, so
16
+ * call `flush()` before a short-lived process exits.
17
+ *
18
+ * Every function follows "telemetry never throws": failures are returned as
19
+ * values and logged once with console.warn.
20
+ */
21
+ import { ATTR } from "./attrs.js";
22
+ // OTel SeverityNumber values, inlined to avoid importing @opentelemetry/api-logs.
23
+ const INFO_SEVERITY = 9;
24
+ const ERROR_SEVERITY = 17;
25
+ const MAX_QUEUE_SIZE = 2048;
26
+ let state;
27
+ let tags = {};
28
+ const warned = new Set();
29
+ function warnOnce(message) {
30
+ if (warned.has(message))
31
+ return;
32
+ warned.add(message);
33
+ console.warn(`[@strada.sh/light] ${message}`);
34
+ }
35
+ function failure(message, cause) {
36
+ warnOnce(message);
37
+ return new Error(message, { cause });
38
+ }
39
+ function isDevMode() {
40
+ try {
41
+ return Boolean(import.meta.hot);
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function toAnyValue(value) {
48
+ if (typeof value === "string")
49
+ return { stringValue: value };
50
+ if (typeof value === "boolean")
51
+ return { boolValue: value };
52
+ if (Number.isInteger(value))
53
+ return { intValue: value };
54
+ return { doubleValue: value };
55
+ }
56
+ function toKeyValues(record) {
57
+ return Object.entries(record).flatMap(([key, value]) => {
58
+ if (value === undefined || value === "")
59
+ return [];
60
+ return [{ key, value: toAnyValue(value) }];
61
+ });
62
+ }
63
+ function normalizeError(value) {
64
+ if (value instanceof Error)
65
+ return value;
66
+ if (typeof value === "string")
67
+ return new Error(value);
68
+ try {
69
+ const message = typeof value === "object" && value !== null ? Reflect.get(value, "message") : undefined;
70
+ return new Error(typeof message === "string" ? message : String(value));
71
+ }
72
+ catch {
73
+ return new Error("Unknown error");
74
+ }
75
+ }
76
+ async function send(current, records) {
77
+ try {
78
+ const response = await fetch(`${current.endpoint}/v1/logs`, {
79
+ method: "POST",
80
+ headers: {
81
+ "content-type": "application/json",
82
+ ...(current.options.token ? { authorization: `Bearer ${current.options.token}` } : {}),
83
+ },
84
+ body: JSON.stringify({
85
+ resourceLogs: [
86
+ {
87
+ resource: { attributes: current.resource },
88
+ scopeLogs: [{ scope: { name: "strada" }, logRecords: records }],
89
+ },
90
+ ],
91
+ }),
92
+ });
93
+ if (!response.ok)
94
+ return failure(`Strada ingest responded ${response.status}`);
95
+ return undefined;
96
+ }
97
+ catch (cause) {
98
+ return failure("Strada ingest request failed", cause);
99
+ }
100
+ }
101
+ /** Queue one log record. Shared by track, identifyUser, and captureException. */
102
+ function emit({ operation, name, body, severity, attributes, }) {
103
+ if (!state) {
104
+ warnOnce(`${operation} called before initStrada(). Nothing was sent.`);
105
+ return undefined;
106
+ }
107
+ const current = state;
108
+ if (!current.exporting)
109
+ return undefined;
110
+ if (current.queue.length >= MAX_QUEUE_SIZE)
111
+ return failure("Queue full, dropping telemetry");
112
+ const userId = typeof current.options.userId === "function" ? current.options.userId() : current.options.userId;
113
+ const time = `${BigInt(Date.now()) * 1000000n}`;
114
+ current.queue.push({
115
+ timeUnixNano: time,
116
+ observedTimeUnixNano: time,
117
+ severityNumber: severity,
118
+ severityText: severity === ERROR_SEVERITY ? "ERROR" : "INFO",
119
+ body: { stringValue: body },
120
+ eventName: name,
121
+ attributes: toKeyValues({ [ATTR["user.id"]]: userId, ...attributes }),
122
+ });
123
+ if (!current.timer) {
124
+ current.timer = setInterval(() => {
125
+ void flush();
126
+ }, current.options.telemetry?.logs?.scheduledDelayMillis ?? 5000);
127
+ // Never keep a CLI or daemon alive just to send telemetry.
128
+ if (typeof current.timer === "object" && typeof current.timer.unref === "function") {
129
+ current.timer.unref();
130
+ }
131
+ }
132
+ if (current.queue.length >= (current.options.telemetry?.logs?.maxExportBatchSize ?? 512)) {
133
+ void flush();
134
+ }
135
+ return undefined;
136
+ }
137
+ export function initStrada(options) {
138
+ try {
139
+ if (state) {
140
+ warnOnce("initStrada() was already called. Ignoring duplicate init.");
141
+ return undefined;
142
+ }
143
+ const endpoint = options.endpoint?.trim()
144
+ ? options.endpoint.replace(/\/+$/, "").toLowerCase()
145
+ : options.projectId?.trim()
146
+ ? `https://${options.projectId}-ingest.strada.sh`.toLowerCase()
147
+ : "";
148
+ if (!endpoint && options.enabled !== false) {
149
+ warnOnce("initStrada() called without a projectId. Telemetry is disabled and all SDK calls are no-ops.");
150
+ }
151
+ state = {
152
+ options,
153
+ endpoint,
154
+ exporting: Boolean(endpoint) && (options.enabled ?? !isDevMode()),
155
+ resource: toKeyValues({
156
+ [ATTR["service.name"]]: options.service,
157
+ [ATTR["service.version"]]: options.version,
158
+ [ATTR["deployment.environment.name"]]: options.environment,
159
+ [ATTR["vcs.ref.head.revision"]]: options.releaseCommit,
160
+ [ATTR["vcs.ref.head.name"]]: options.releaseBranch,
161
+ [ATTR["deployment.id"]]: options.deploymentId ?? options.releaseCommit,
162
+ }),
163
+ queue: [],
164
+ inflight: Promise.resolve(undefined),
165
+ timer: undefined,
166
+ };
167
+ return undefined;
168
+ }
169
+ catch (cause) {
170
+ return failure("initStrada() failed", cause);
171
+ }
172
+ }
173
+ /** Product analytics event. Properties are stored as `custom.*` attributes. */
174
+ export function track(name, properties) {
175
+ try {
176
+ const custom = Object.fromEntries(Object.entries(properties ?? {}).map(([key, value]) => {
177
+ return [`custom.${key}`, value];
178
+ }));
179
+ return emit({
180
+ operation: "track()",
181
+ name,
182
+ body: name,
183
+ severity: INFO_SEVERITY,
184
+ attributes: { [ATTR["event.name"]]: name, ...custom },
185
+ });
186
+ }
187
+ catch (cause) {
188
+ return failure("track() failed", cause);
189
+ }
190
+ }
191
+ /** Full profile snapshot for `otel_users`. Call from trusted code with every field you want to keep. */
192
+ export function identifyUser(user) {
193
+ try {
194
+ const name = ATTR["strada.user.identify"];
195
+ return emit({
196
+ operation: "identifyUser()",
197
+ name,
198
+ body: name,
199
+ severity: INFO_SEVERITY,
200
+ attributes: {
201
+ [ATTR["event.name"]]: name,
202
+ [ATTR["user.id"]]: user.id,
203
+ [ATTR["user.email"]]: user.email,
204
+ [ATTR["user.name"]]: user.name,
205
+ [ATTR["user.full_name"]]: user.fullName,
206
+ [ATTR["user.hash"]]: user.hash,
207
+ [ATTR["user.image"]]: user.image,
208
+ [ATTR["organization.id"]]: user.organizationId,
209
+ [ATTR["organization.name"]]: user.organizationName,
210
+ ...Object.fromEntries(Object.entries(user.attributes ?? {}).map(([key, value]) => {
211
+ return [`strada.user.attributes.${key}`, value];
212
+ })),
213
+ },
214
+ });
215
+ }
216
+ catch (cause) {
217
+ return failure("identifyUser() failed", cause);
218
+ }
219
+ }
220
+ /** Tags merged into every captureException() call. */
221
+ export function setTags(next) {
222
+ tags = { ...tags, ...next };
223
+ }
224
+ /**
225
+ * Report a handled error as an issue. No ignoreErrors, denyUrls, or
226
+ * beforeSend in the light build; KnownError instances are skipped like in
227
+ * the full SDK.
228
+ */
229
+ export function captureException(error, opts) {
230
+ try {
231
+ const normalized = normalizeError(error);
232
+ if (normalized.name === "KnownError" || normalized.constructor?.name === "KnownError")
233
+ return undefined;
234
+ const fingerprintValue = Reflect.get(normalized, "fingerprint");
235
+ const fingerprint = opts?.fingerprint ?? (Array.isArray(fingerprintValue) ? fingerprintValue : undefined);
236
+ return emit({
237
+ operation: "captureException()",
238
+ name: "exception",
239
+ body: normalized.message,
240
+ severity: ERROR_SEVERITY,
241
+ attributes: {
242
+ [ATTR["exception.type"]]: normalized.name || "Error",
243
+ [ATTR["exception.message"]]: normalized.message || "",
244
+ [ATTR["exception.stacktrace"]]: normalized.stack ?? "",
245
+ [ATTR["exception.mechanism.type"]]: opts?.mechanism ?? "generic",
246
+ [ATTR["exception.mechanism.handled"]]: String(opts?.handled ?? true),
247
+ [ATTR["exception.fingerprint"]]: fingerprint ? JSON.stringify(fingerprint) : undefined,
248
+ ...tags,
249
+ ...opts?.tags,
250
+ },
251
+ });
252
+ }
253
+ catch (cause) {
254
+ return failure("captureException() failed", cause);
255
+ }
256
+ }
257
+ export function flush() {
258
+ const current = state;
259
+ if (!current)
260
+ return Promise.resolve(undefined);
261
+ if (current.queue.length === 0)
262
+ return current.inflight;
263
+ const records = current.queue;
264
+ current.queue = [];
265
+ // Chain sends so flush() resolves only after every earlier batch is done.
266
+ current.inflight = current.inflight.then(() => {
267
+ return send(current, records);
268
+ });
269
+ return current.inflight;
270
+ }
271
+ export async function shutdown() {
272
+ const current = state;
273
+ if (!current)
274
+ return undefined;
275
+ clearInterval(current.timer);
276
+ const error = await flush();
277
+ state = undefined;
278
+ return error;
279
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@strada.sh/light",
3
+ "version": "0.1.0",
4
+ "description": "Zero-dependency Strada SDK subset for product events, user profiles, and handled errors. Drop-in for @strada.sh/sdk: same names, same options, no OpenTelemetry.",
5
+ "keywords": [
6
+ "strada",
7
+ "analytics",
8
+ "product-analytics",
9
+ "telemetry",
10
+ "opentelemetry",
11
+ "otlp"
12
+ ],
13
+ "homepage": "https://github.com/remorses/strada",
14
+ "bugs": "https://github.com/remorses/strada/issues",
15
+ "license": "MIT",
16
+ "author": "remorses <beats.by.morse@gmail.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/remorses/strada",
20
+ "directory": "light"
21
+ },
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ "./package.json": "./package.json",
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./src": {
33
+ "types": "./src/index.ts",
34
+ "default": "./src/index.ts"
35
+ }
36
+ },
37
+ "files": [
38
+ "src",
39
+ "dist",
40
+ "!**/*.test.*"
41
+ ],
42
+ "devDependencies": {
43
+ "@types/node": "^25.9.6",
44
+ "rimraf": "^6.1.3",
45
+ "@strada.sh/sdk": "^0.7.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc",
49
+ "test": "vitest run src",
50
+ "typecheck": "tsc"
51
+ }
52
+ }
package/src/attrs.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Subset of the full SDK attribute keys. Values must match `@strada.sh/sdk` ATTR. */
2
+ export const ATTR = {
3
+ "service.name": "service.name",
4
+ "service.version": "service.version",
5
+ "deployment.environment.name": "deployment.environment.name",
6
+ "deployment.id": "deployment.id",
7
+ "vcs.ref.head.revision": "vcs.ref.head.revision",
8
+ "vcs.ref.head.name": "vcs.ref.head.name",
9
+ "event.name": "event.name",
10
+ "user.id": "user.id",
11
+ "user.email": "user.email",
12
+ "user.name": "user.name",
13
+ "user.full_name": "user.full_name",
14
+ "user.hash": "user.hash",
15
+ "user.image": "user.image",
16
+ "organization.id": "organization.id",
17
+ "organization.name": "organization.name",
18
+ "strada.user.identify": "strada.user.identify",
19
+ "exception.type": "exception.type",
20
+ "exception.message": "exception.message",
21
+ "exception.stacktrace": "exception.stacktrace",
22
+ "exception.mechanism.type": "exception.mechanism.type",
23
+ "exception.mechanism.handled": "exception.mechanism.handled",
24
+ "exception.fingerprint": "exception.fingerprint",
25
+ } as const;
package/src/index.ts ADDED
@@ -0,0 +1,385 @@
1
+ /**
2
+ * `@strada.sh/light`: a zero-dependency, drop-in subset of `@strada.sh/sdk`.
3
+ *
4
+ * Switch by changing the import path. Same function names, same option
5
+ * names, same `otel_logs` rows (`event.name`, `custom.*`, `exception.*`,
6
+ * `strada.user.identify`), so every Strada query works unchanged. What the
7
+ * light build does not support is not exported or not accepted, so a switch
8
+ * that relies on it fails at compile time instead of silently dropping data.
9
+ * `index.test.ts` type-checks this file against the full SDK to keep both in
10
+ * sync.
11
+ *
12
+ * It builds OTLP JSON log records by hand and POSTs them with `fetch` to
13
+ * `/v1/logs`. `initStrada()` installs nothing global: no OTel providers, no
14
+ * process handlers, no resource detectors (no hostname, OS username, or
15
+ * command args). Only what you pass is sent. The flush timer is unref'd, so
16
+ * call `flush()` before a short-lived process exits.
17
+ *
18
+ * Every function follows "telemetry never throws": failures are returned as
19
+ * values and logged once with console.warn.
20
+ */
21
+
22
+ import { ATTR } from "./attrs.ts";
23
+
24
+ export interface StradaOptions {
25
+ /** Strada project identifier. Blank disables sending. */
26
+ projectId: string;
27
+ /** service.name resource attribute */
28
+ service: string;
29
+ /** Kill switch. `false` makes every call a no-op. Defaults to true, false when import.meta.hot is set. */
30
+ enabled?: boolean;
31
+ /** Override the ingest endpoint. Defaults to https://{projectId}-ingest.strada.sh */
32
+ endpoint?: string;
33
+ /** Server-side ingest token. Omit in code shipped to users (npm CLIs, browsers). */
34
+ token?: string;
35
+ /** service.version resource attribute */
36
+ version?: string;
37
+ /** deployment.environment.name resource attribute */
38
+ environment?: string;
39
+ /** vcs.ref.head.revision resource attribute */
40
+ releaseCommit?: string;
41
+ /** vcs.ref.head.name resource attribute */
42
+ releaseBranch?: string;
43
+ /** deployment.id resource attribute. Defaults to releaseCommit. */
44
+ deploymentId?: string;
45
+ /** Current user id, sent as user.id on every event and error. */
46
+ userId?: string | (() => string | undefined);
47
+ /** Same shape as the full SDK. Only log batching applies here. */
48
+ telemetry?: {
49
+ logs?: { scheduledDelayMillis?: number; maxExportBatchSize?: number };
50
+ };
51
+ }
52
+
53
+ export interface StradaUserIdentity {
54
+ /** Stable application user id. */
55
+ id: string;
56
+ /** User email. PII, sent only through identifyUser profile events. */
57
+ email?: string;
58
+ /** Display name or username. */
59
+ name?: string;
60
+ /** Full human-readable name. */
61
+ fullName?: string;
62
+ /** Stable anonymized user hash when raw ids are sensitive. */
63
+ hash?: string;
64
+ /** Profile image URL. */
65
+ image?: string;
66
+ /** Product/account organization id for this user profile. */
67
+ organizationId?: string;
68
+ /** Product/account organization name for this user profile. */
69
+ organizationName?: string;
70
+ /** Additional low-cardinality profile attributes. */
71
+ attributes?: Record<string, string>;
72
+ }
73
+
74
+ export interface CaptureExceptionOptions {
75
+ /** Was this error caught by user code (true) or a global handler (false)? */
76
+ handled?: boolean;
77
+ /** How the exception was captured, e.g. onerror or unhandledrejection */
78
+ mechanism?: string;
79
+ /** Extra tags attached to the error */
80
+ tags?: Record<string, string>;
81
+ /** Custom fingerprint override for issue grouping. */
82
+ fingerprint?: string[];
83
+ }
84
+
85
+ type AttributeValue = string | number | boolean;
86
+
87
+ type OtlpAnyValue =
88
+ | { stringValue: string }
89
+ | { intValue: number }
90
+ | { doubleValue: number }
91
+ | { boolValue: boolean };
92
+
93
+ type OtlpKeyValue = { key: string; value: OtlpAnyValue };
94
+
95
+ type OtlpLogRecord = {
96
+ timeUnixNano: string;
97
+ observedTimeUnixNano: string;
98
+ severityNumber: number;
99
+ severityText: string;
100
+ body: { stringValue: string };
101
+ eventName: string;
102
+ attributes: OtlpKeyValue[];
103
+ };
104
+
105
+ type LightState = {
106
+ options: StradaOptions;
107
+ endpoint: string;
108
+ exporting: boolean;
109
+ resource: OtlpKeyValue[];
110
+ queue: OtlpLogRecord[];
111
+ inflight: Promise<Error | undefined>;
112
+ timer: ReturnType<typeof setInterval> | undefined;
113
+ };
114
+
115
+ // OTel SeverityNumber values, inlined to avoid importing @opentelemetry/api-logs.
116
+ const INFO_SEVERITY = 9;
117
+ const ERROR_SEVERITY = 17;
118
+ const MAX_QUEUE_SIZE = 2048;
119
+
120
+ let state: LightState | undefined;
121
+ let tags: Record<string, string> = {};
122
+
123
+ const warned = new Set<string>();
124
+ function warnOnce(message: string): void {
125
+ if (warned.has(message)) return;
126
+ warned.add(message);
127
+ console.warn(`[@strada.sh/light] ${message}`);
128
+ }
129
+
130
+ function failure(message: string, cause?: unknown): Error {
131
+ warnOnce(message);
132
+ return new Error(message, { cause });
133
+ }
134
+
135
+ function isDevMode(): boolean {
136
+ try {
137
+ return Boolean((import.meta as { hot?: unknown }).hot);
138
+ } catch {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function toAnyValue(value: AttributeValue): OtlpAnyValue {
144
+ if (typeof value === "string") return { stringValue: value };
145
+ if (typeof value === "boolean") return { boolValue: value };
146
+ if (Number.isInteger(value)) return { intValue: value };
147
+ return { doubleValue: value };
148
+ }
149
+
150
+ function toKeyValues(record: Record<string, AttributeValue | undefined>): OtlpKeyValue[] {
151
+ return Object.entries(record).flatMap(([key, value]) => {
152
+ if (value === undefined || value === "") return [];
153
+ return [{ key, value: toAnyValue(value) }];
154
+ });
155
+ }
156
+
157
+ function normalizeError(value: unknown): Error {
158
+ if (value instanceof Error) return value;
159
+ if (typeof value === "string") return new Error(value);
160
+ try {
161
+ const message = typeof value === "object" && value !== null ? Reflect.get(value, "message") : undefined;
162
+ return new Error(typeof message === "string" ? message : String(value));
163
+ } catch {
164
+ return new Error("Unknown error");
165
+ }
166
+ }
167
+
168
+ async function send(current: LightState, records: OtlpLogRecord[]): Promise<Error | undefined> {
169
+ try {
170
+ const response = await fetch(`${current.endpoint}/v1/logs`, {
171
+ method: "POST",
172
+ headers: {
173
+ "content-type": "application/json",
174
+ ...(current.options.token ? { authorization: `Bearer ${current.options.token}` } : {}),
175
+ },
176
+ body: JSON.stringify({
177
+ resourceLogs: [
178
+ {
179
+ resource: { attributes: current.resource },
180
+ scopeLogs: [{ scope: { name: "strada" }, logRecords: records }],
181
+ },
182
+ ],
183
+ }),
184
+ });
185
+ if (!response.ok) return failure(`Strada ingest responded ${response.status}`);
186
+ return undefined;
187
+ } catch (cause) {
188
+ return failure("Strada ingest request failed", cause);
189
+ }
190
+ }
191
+
192
+ /** Queue one log record. Shared by track, identifyUser, and captureException. */
193
+ function emit({
194
+ operation,
195
+ name,
196
+ body,
197
+ severity,
198
+ attributes,
199
+ }: {
200
+ operation: string;
201
+ name: string;
202
+ body: string;
203
+ severity: number;
204
+ attributes: Record<string, AttributeValue | undefined>;
205
+ }): Error | undefined {
206
+ if (!state) {
207
+ warnOnce(`${operation} called before initStrada(). Nothing was sent.`);
208
+ return undefined;
209
+ }
210
+ const current = state;
211
+ if (!current.exporting) return undefined;
212
+ if (current.queue.length >= MAX_QUEUE_SIZE) return failure("Queue full, dropping telemetry");
213
+
214
+ const userId = typeof current.options.userId === "function" ? current.options.userId() : current.options.userId;
215
+ const time = `${BigInt(Date.now()) * 1_000_000n}`;
216
+ current.queue.push({
217
+ timeUnixNano: time,
218
+ observedTimeUnixNano: time,
219
+ severityNumber: severity,
220
+ severityText: severity === ERROR_SEVERITY ? "ERROR" : "INFO",
221
+ body: { stringValue: body },
222
+ eventName: name,
223
+ attributes: toKeyValues({ [ATTR["user.id"]]: userId, ...attributes }),
224
+ });
225
+
226
+ if (!current.timer) {
227
+ current.timer = setInterval(() => {
228
+ void flush();
229
+ }, current.options.telemetry?.logs?.scheduledDelayMillis ?? 5000);
230
+ // Never keep a CLI or daemon alive just to send telemetry.
231
+ if (typeof current.timer === "object" && typeof current.timer.unref === "function") {
232
+ current.timer.unref();
233
+ }
234
+ }
235
+ if (current.queue.length >= (current.options.telemetry?.logs?.maxExportBatchSize ?? 512)) {
236
+ void flush();
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ export function initStrada(options: StradaOptions): Error | undefined {
242
+ try {
243
+ if (state) {
244
+ warnOnce("initStrada() was already called. Ignoring duplicate init.");
245
+ return undefined;
246
+ }
247
+ const endpoint = options.endpoint?.trim()
248
+ ? options.endpoint.replace(/\/+$/, "").toLowerCase()
249
+ : options.projectId?.trim()
250
+ ? `https://${options.projectId}-ingest.strada.sh`.toLowerCase()
251
+ : "";
252
+ if (!endpoint && options.enabled !== false) {
253
+ warnOnce("initStrada() called without a projectId. Telemetry is disabled and all SDK calls are no-ops.");
254
+ }
255
+ state = {
256
+ options,
257
+ endpoint,
258
+ exporting: Boolean(endpoint) && (options.enabled ?? !isDevMode()),
259
+ resource: toKeyValues({
260
+ [ATTR["service.name"]]: options.service,
261
+ [ATTR["service.version"]]: options.version,
262
+ [ATTR["deployment.environment.name"]]: options.environment,
263
+ [ATTR["vcs.ref.head.revision"]]: options.releaseCommit,
264
+ [ATTR["vcs.ref.head.name"]]: options.releaseBranch,
265
+ [ATTR["deployment.id"]]: options.deploymentId ?? options.releaseCommit,
266
+ }),
267
+ queue: [],
268
+ inflight: Promise.resolve(undefined),
269
+ timer: undefined,
270
+ };
271
+ return undefined;
272
+ } catch (cause) {
273
+ return failure("initStrada() failed", cause);
274
+ }
275
+ }
276
+
277
+ /** Product analytics event. Properties are stored as `custom.*` attributes. */
278
+ export function track(name: string, properties?: Record<string, AttributeValue>): Error | undefined {
279
+ try {
280
+ const custom = Object.fromEntries(
281
+ Object.entries(properties ?? {}).map(([key, value]) => {
282
+ return [`custom.${key}`, value];
283
+ }),
284
+ );
285
+ return emit({
286
+ operation: "track()",
287
+ name,
288
+ body: name,
289
+ severity: INFO_SEVERITY,
290
+ attributes: { [ATTR["event.name"]]: name, ...custom },
291
+ });
292
+ } catch (cause) {
293
+ return failure("track() failed", cause);
294
+ }
295
+ }
296
+
297
+ /** Full profile snapshot for `otel_users`. Call from trusted code with every field you want to keep. */
298
+ export function identifyUser(user: StradaUserIdentity): Error | undefined {
299
+ try {
300
+ const name = ATTR["strada.user.identify"];
301
+ return emit({
302
+ operation: "identifyUser()",
303
+ name,
304
+ body: name,
305
+ severity: INFO_SEVERITY,
306
+ attributes: {
307
+ [ATTR["event.name"]]: name,
308
+ [ATTR["user.id"]]: user.id,
309
+ [ATTR["user.email"]]: user.email,
310
+ [ATTR["user.name"]]: user.name,
311
+ [ATTR["user.full_name"]]: user.fullName,
312
+ [ATTR["user.hash"]]: user.hash,
313
+ [ATTR["user.image"]]: user.image,
314
+ [ATTR["organization.id"]]: user.organizationId,
315
+ [ATTR["organization.name"]]: user.organizationName,
316
+ ...Object.fromEntries(
317
+ Object.entries(user.attributes ?? {}).map(([key, value]) => {
318
+ return [`strada.user.attributes.${key}`, value];
319
+ }),
320
+ ),
321
+ },
322
+ });
323
+ } catch (cause) {
324
+ return failure("identifyUser() failed", cause);
325
+ }
326
+ }
327
+
328
+ /** Tags merged into every captureException() call. */
329
+ export function setTags(next: Record<string, string>): void {
330
+ tags = { ...tags, ...next };
331
+ }
332
+
333
+ /**
334
+ * Report a handled error as an issue. No ignoreErrors, denyUrls, or
335
+ * beforeSend in the light build; KnownError instances are skipped like in
336
+ * the full SDK.
337
+ */
338
+ export function captureException(error: unknown, opts?: CaptureExceptionOptions): Error | undefined {
339
+ try {
340
+ const normalized = normalizeError(error);
341
+ if (normalized.name === "KnownError" || normalized.constructor?.name === "KnownError") return undefined;
342
+ const fingerprintValue = Reflect.get(normalized, "fingerprint");
343
+ const fingerprint = opts?.fingerprint ?? (Array.isArray(fingerprintValue) ? fingerprintValue : undefined);
344
+ return emit({
345
+ operation: "captureException()",
346
+ name: "exception",
347
+ body: normalized.message,
348
+ severity: ERROR_SEVERITY,
349
+ attributes: {
350
+ [ATTR["exception.type"]]: normalized.name || "Error",
351
+ [ATTR["exception.message"]]: normalized.message || "",
352
+ [ATTR["exception.stacktrace"]]: normalized.stack ?? "",
353
+ [ATTR["exception.mechanism.type"]]: opts?.mechanism ?? "generic",
354
+ [ATTR["exception.mechanism.handled"]]: String(opts?.handled ?? true),
355
+ [ATTR["exception.fingerprint"]]: fingerprint ? JSON.stringify(fingerprint) : undefined,
356
+ ...tags,
357
+ ...opts?.tags,
358
+ },
359
+ });
360
+ } catch (cause) {
361
+ return failure("captureException() failed", cause);
362
+ }
363
+ }
364
+
365
+ export function flush(): Promise<Error | undefined> {
366
+ const current = state;
367
+ if (!current) return Promise.resolve(undefined);
368
+ if (current.queue.length === 0) return current.inflight;
369
+ const records = current.queue;
370
+ current.queue = [];
371
+ // Chain sends so flush() resolves only after every earlier batch is done.
372
+ current.inflight = current.inflight.then(() => {
373
+ return send(current, records);
374
+ });
375
+ return current.inflight;
376
+ }
377
+
378
+ export async function shutdown(): Promise<Error | undefined> {
379
+ const current = state;
380
+ if (!current) return undefined;
381
+ clearInterval(current.timer);
382
+ const error = await flush();
383
+ state = undefined;
384
+ return error;
385
+ }