@owlmetry/node 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,13 @@
1
+ import type { OwlConfiguration } from "./types.js";
2
+ export interface ValidatedConfig {
3
+ endpoint: string;
4
+ apiKey: string;
5
+ serviceName: string;
6
+ appVersion?: string;
7
+ debug: boolean;
8
+ isDev: boolean;
9
+ flushIntervalMs: number;
10
+ flushThreshold: number;
11
+ maxBufferSize: number;
12
+ }
13
+ export declare function validateConfiguration(config: OwlConfiguration): ValidatedConfig;
@@ -0,0 +1,34 @@
1
+ const CLIENT_KEY_PREFIX = "owl_client_";
2
+ export function validateConfiguration(config) {
3
+ if (!config.endpoint || typeof config.endpoint !== "string") {
4
+ throw new Error("OwlMetry: endpoint is required");
5
+ }
6
+ let endpoint = config.endpoint;
7
+ // Strip trailing slash
8
+ if (endpoint.endsWith("/")) {
9
+ endpoint = endpoint.slice(0, -1);
10
+ }
11
+ try {
12
+ new URL(endpoint);
13
+ }
14
+ catch {
15
+ throw new Error(`OwlMetry: invalid endpoint URL: ${endpoint}`);
16
+ }
17
+ if (!config.apiKey || typeof config.apiKey !== "string") {
18
+ throw new Error("OwlMetry: apiKey is required");
19
+ }
20
+ if (!config.apiKey.startsWith(CLIENT_KEY_PREFIX)) {
21
+ throw new Error(`OwlMetry: apiKey must start with "${CLIENT_KEY_PREFIX}"`);
22
+ }
23
+ return {
24
+ endpoint,
25
+ apiKey: config.apiKey,
26
+ serviceName: config.serviceName || "unknown",
27
+ appVersion: config.appVersion,
28
+ debug: config.debug ?? false,
29
+ isDev: config.isDev ?? (process.env.NODE_ENV !== "production"),
30
+ flushIntervalMs: config.flushIntervalMs ?? 5000,
31
+ flushThreshold: config.flushThreshold ?? 20,
32
+ maxBufferSize: config.maxBufferSize ?? 10000,
33
+ };
34
+ }
@@ -0,0 +1,90 @@
1
+ import type { OwlConfiguration } from "./types.js";
2
+ import { Operation } from "./operation.js";
3
+ export type { OwlConfiguration, LogLevel, LogEvent } from "./types.js";
4
+ export { Operation } from "./operation.js";
5
+ /**
6
+ * A scoped logger instance that automatically sets a user ID on all events.
7
+ */
8
+ export declare class ScopedOwl {
9
+ private userId;
10
+ constructor(userId: string);
11
+ info(message: string, attrs?: Record<string, unknown>): void;
12
+ debug(message: string, attrs?: Record<string, unknown>): void;
13
+ warn(message: string, attrs?: Record<string, unknown>): void;
14
+ error(message: string, attrs?: Record<string, unknown>): void;
15
+ /**
16
+ * Track a named step (e.g. funnel step, user action). Sends an info-level event
17
+ * with message `track:<stepName>`.
18
+ */
19
+ track(stepName: string, attributes?: Record<string, string>): void;
20
+ /**
21
+ * Start a tracked operation. The `metric` slug should contain only lowercase letters,
22
+ * numbers, and hyphens (e.g. "photo-conversion", "api-request"). Invalid characters
23
+ * are auto-corrected with a warning logged in debug mode.
24
+ */
25
+ startOperation(metric: string, attrs?: Record<string, unknown>): Operation;
26
+ /**
27
+ * Record a single-shot metric. The `metric` slug should contain only lowercase letters,
28
+ * numbers, and hyphens (e.g. "onboarding", "checkout"). Invalid characters are
29
+ * auto-corrected with a warning logged in debug mode.
30
+ */
31
+ recordMetric(metric: string, attrs?: Record<string, unknown>): void;
32
+ }
33
+ /**
34
+ * OwlMetry Node.js Server SDK.
35
+ *
36
+ * Usage:
37
+ * ```
38
+ * import { Owl } from '@owlmetry/node';
39
+ *
40
+ * Owl.configure({ endpoint: 'https://...', apiKey: 'owl_client_...' });
41
+ * Owl.info('Server started');
42
+ *
43
+ * const owl = Owl.withUser('user_123');
44
+ * owl.info('User logged in');
45
+ *
46
+ * await Owl.shutdown();
47
+ * ```
48
+ */
49
+ export declare const Owl: {
50
+ configure(options: OwlConfiguration): void;
51
+ info(message: string, attrs?: Record<string, unknown>): void;
52
+ debug(message: string, attrs?: Record<string, unknown>): void;
53
+ warn(message: string, attrs?: Record<string, unknown>): void;
54
+ error(message: string, attrs?: Record<string, unknown>): void;
55
+ /**
56
+ * Track a named step (e.g. funnel step, user action). Sends an info-level event
57
+ * with message `track:<stepName>`.
58
+ */
59
+ track(stepName: string, attributes?: Record<string, string>): void;
60
+ /**
61
+ * Get the assigned variant for an experiment. On first call, picks a random variant
62
+ * from `options` and persists the assignment. Future calls return the stored variant
63
+ * (the `options` parameter is ignored after the first assignment).
64
+ */
65
+ getVariant(name: string, options: string[]): string;
66
+ /**
67
+ * Force a specific variant for an experiment. Persists immediately.
68
+ */
69
+ setExperiment(name: string, variant: string): void;
70
+ /**
71
+ * Reset all experiment assignments. Persists immediately.
72
+ */
73
+ clearExperiments(): void;
74
+ /**
75
+ * Start a tracked operation. The `metric` slug should contain only lowercase letters,
76
+ * numbers, and hyphens (e.g. "photo-conversion", "api-request"). Invalid characters
77
+ * are auto-corrected with a warning logged in debug mode.
78
+ */
79
+ startOperation(metric: string, attrs?: Record<string, unknown>): Operation;
80
+ /**
81
+ * Record a single-shot metric. The `metric` slug should contain only lowercase letters,
82
+ * numbers, and hyphens (e.g. "onboarding", "checkout"). Invalid characters are
83
+ * auto-corrected with a warning logged in debug mode.
84
+ */
85
+ recordMetric(metric: string, attrs?: Record<string, unknown>): void;
86
+ withUser(userId: string): ScopedOwl;
87
+ flush(): Promise<void>;
88
+ wrapHandler<TArgs extends unknown[], TReturn>(handler: (...args: TArgs) => Promise<TReturn>): (...args: TArgs) => Promise<TReturn>;
89
+ shutdown(): Promise<void>;
90
+ };
@@ -0,0 +1,299 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { validateConfiguration } from "./configuration.js";
6
+ import { Transport } from "./transport.js";
7
+ import { Operation } from "./operation.js";
8
+ export { Operation } from "./operation.js";
9
+ const MAX_ATTRIBUTE_VALUE_LENGTH = 200;
10
+ const SLUG_REGEX = /^[a-z0-9-]+$/;
11
+ const TRACK_MESSAGE_PREFIX = "track:";
12
+ const EXPERIMENTS_DIR = join(homedir(), ".owlmetry");
13
+ const EXPERIMENTS_FILE = join(EXPERIMENTS_DIR, "experiments.json");
14
+ let experiments = {};
15
+ function loadExperiments() {
16
+ try {
17
+ const data = readFileSync(EXPERIMENTS_FILE, "utf-8");
18
+ const parsed = JSON.parse(data);
19
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
20
+ experiments = parsed;
21
+ }
22
+ }
23
+ catch {
24
+ // File doesn't exist or is invalid — start with empty experiments
25
+ experiments = {};
26
+ }
27
+ }
28
+ function saveExperiments() {
29
+ try {
30
+ mkdirSync(EXPERIMENTS_DIR, { recursive: true });
31
+ writeFileSync(EXPERIMENTS_FILE, JSON.stringify(experiments, null, 2), "utf-8");
32
+ }
33
+ catch (err) {
34
+ if (config?.debug) {
35
+ console.error("OwlMetry: failed to save experiments:", err);
36
+ }
37
+ }
38
+ }
39
+ /**
40
+ * Normalize a metric slug to contain only lowercase letters, numbers, and hyphens.
41
+ * Logs a warning if the slug was modified. Returns the normalized slug.
42
+ */
43
+ function normalizeSlug(slug) {
44
+ if (SLUG_REGEX.test(slug))
45
+ return slug;
46
+ const normalized = slug
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9-]/g, "-")
49
+ .replace(/-{2,}/g, "-")
50
+ .replace(/^-+|-+$/g, "");
51
+ if (config?.debug) {
52
+ console.error(`OwlMetry: metric slug "${slug}" was auto-corrected to "${normalized}". Slugs should contain only lowercase letters, numbers, and hyphens.`);
53
+ }
54
+ return normalized;
55
+ }
56
+ function getSourceModule() {
57
+ const err = new Error();
58
+ const stack = err.stack;
59
+ if (!stack)
60
+ return undefined;
61
+ const lines = stack.split("\n");
62
+ // Skip: Error, at Object.<method> (index.ts), at Owl.<method> / ScopedOwl.<method>
63
+ // Find the first frame outside this file
64
+ for (let i = 3; i < lines.length; i++) {
65
+ const line = lines[i].trim();
66
+ if (line.includes("node:") || line.includes("node_modules"))
67
+ continue;
68
+ // Extract file:line from "at <something> (file:line:col)" or "at file:line:col"
69
+ const parenMatch = line.match(/\((.+):(\d+):\d+\)$/);
70
+ if (parenMatch)
71
+ return `${parenMatch[1]}:${parenMatch[2]}`;
72
+ const directMatch = line.match(/at (.+):(\d+):\d+$/);
73
+ if (directMatch)
74
+ return `${directMatch[1]}:${directMatch[2]}`;
75
+ }
76
+ return undefined;
77
+ }
78
+ function normalizeAttributes(attrs) {
79
+ if (!attrs || Object.keys(attrs).length === 0)
80
+ return undefined;
81
+ const result = {};
82
+ for (const [key, value] of Object.entries(attrs)) {
83
+ let str = String(value);
84
+ if (str.length > MAX_ATTRIBUTE_VALUE_LENGTH) {
85
+ str = str.slice(0, MAX_ATTRIBUTE_VALUE_LENGTH);
86
+ }
87
+ result[key] = str;
88
+ }
89
+ return result;
90
+ }
91
+ let config = null;
92
+ let transport = null;
93
+ let sessionId = null;
94
+ let beforeExitRegistered = false;
95
+ function ensureConfigured() {
96
+ if (!config || !transport || !sessionId) {
97
+ throw new Error("OwlMetry: not configured. Call Owl.configure() first.");
98
+ }
99
+ return { config, transport, sessionId };
100
+ }
101
+ function createEvent(ctx, level, message, attrs, userId) {
102
+ return {
103
+ client_event_id: randomUUID(),
104
+ session_id: ctx.sessionId,
105
+ ...(userId ? { user_id: userId } : {}),
106
+ level,
107
+ source_module: getSourceModule(),
108
+ message,
109
+ custom_attributes: normalizeAttributes(attrs),
110
+ ...(Object.keys(experiments).length > 0 ? { experiments: { ...experiments } } : {}),
111
+ environment: "backend",
112
+ ...(ctx.config.appVersion ? { app_version: ctx.config.appVersion } : {}),
113
+ is_dev: ctx.config.isDev,
114
+ timestamp: new Date().toISOString(),
115
+ };
116
+ }
117
+ function log(level, message, attrs, userId) {
118
+ try {
119
+ const ctx = ensureConfigured();
120
+ const event = createEvent(ctx, level, message, attrs, userId);
121
+ ctx.transport.enqueue(event);
122
+ }
123
+ catch (err) {
124
+ if (config?.debug) {
125
+ console.error("OwlMetry:", err);
126
+ }
127
+ }
128
+ }
129
+ /**
130
+ * A scoped logger instance that automatically sets a user ID on all events.
131
+ */
132
+ export class ScopedOwl {
133
+ userId;
134
+ constructor(userId) {
135
+ this.userId = userId;
136
+ }
137
+ info(message, attrs) {
138
+ log("info", message, attrs, this.userId);
139
+ }
140
+ debug(message, attrs) {
141
+ log("debug", message, attrs, this.userId);
142
+ }
143
+ warn(message, attrs) {
144
+ log("warn", message, attrs, this.userId);
145
+ }
146
+ error(message, attrs) {
147
+ log("error", message, attrs, this.userId);
148
+ }
149
+ /**
150
+ * Track a named step (e.g. funnel step, user action). Sends an info-level event
151
+ * with message `track:<stepName>`.
152
+ */
153
+ track(stepName, attributes) {
154
+ log("info", `${TRACK_MESSAGE_PREFIX}${stepName}`, attributes, this.userId);
155
+ }
156
+ /**
157
+ * Start a tracked operation. The `metric` slug should contain only lowercase letters,
158
+ * numbers, and hyphens (e.g. "photo-conversion", "api-request"). Invalid characters
159
+ * are auto-corrected with a warning logged in debug mode.
160
+ */
161
+ startOperation(metric, attrs) {
162
+ return new Operation(log, normalizeSlug(metric), attrs, this.userId);
163
+ }
164
+ /**
165
+ * Record a single-shot metric. The `metric` slug should contain only lowercase letters,
166
+ * numbers, and hyphens (e.g. "onboarding", "checkout"). Invalid characters are
167
+ * auto-corrected with a warning logged in debug mode.
168
+ */
169
+ recordMetric(metric, attrs) {
170
+ log("info", `metric:${normalizeSlug(metric)}:record`, attrs, this.userId);
171
+ }
172
+ }
173
+ /**
174
+ * OwlMetry Node.js Server SDK.
175
+ *
176
+ * Usage:
177
+ * ```
178
+ * import { Owl } from '@owlmetry/node';
179
+ *
180
+ * Owl.configure({ endpoint: 'https://...', apiKey: 'owl_client_...' });
181
+ * Owl.info('Server started');
182
+ *
183
+ * const owl = Owl.withUser('user_123');
184
+ * owl.info('User logged in');
185
+ *
186
+ * await Owl.shutdown();
187
+ * ```
188
+ */
189
+ export const Owl = {
190
+ configure(options) {
191
+ // Clean up previous transport if reconfiguring
192
+ if (transport) {
193
+ transport.shutdown().catch(() => { });
194
+ }
195
+ config = validateConfiguration(options);
196
+ transport = new Transport(config);
197
+ sessionId = randomUUID();
198
+ loadExperiments();
199
+ if (!beforeExitRegistered) {
200
+ beforeExitRegistered = true;
201
+ process.on("beforeExit", async () => {
202
+ if (transport && transport.bufferSize > 0) {
203
+ await transport.flush();
204
+ }
205
+ });
206
+ }
207
+ },
208
+ info(message, attrs) {
209
+ log("info", message, attrs);
210
+ },
211
+ debug(message, attrs) {
212
+ log("debug", message, attrs);
213
+ },
214
+ warn(message, attrs) {
215
+ log("warn", message, attrs);
216
+ },
217
+ error(message, attrs) {
218
+ log("error", message, attrs);
219
+ },
220
+ /**
221
+ * Track a named step (e.g. funnel step, user action). Sends an info-level event
222
+ * with message `track:<stepName>`.
223
+ */
224
+ track(stepName, attributes) {
225
+ log("info", `${TRACK_MESSAGE_PREFIX}${stepName}`, attributes);
226
+ },
227
+ /**
228
+ * Get the assigned variant for an experiment. On first call, picks a random variant
229
+ * from `options` and persists the assignment. Future calls return the stored variant
230
+ * (the `options` parameter is ignored after the first assignment).
231
+ */
232
+ getVariant(name, options) {
233
+ if (experiments[name]) {
234
+ return experiments[name];
235
+ }
236
+ if (options.length === 0) {
237
+ throw new Error(`OwlMetry: getVariant("${name}") called with empty options array.`);
238
+ }
239
+ const variant = options[Math.floor(Math.random() * options.length)];
240
+ experiments[name] = variant;
241
+ saveExperiments();
242
+ return variant;
243
+ },
244
+ /**
245
+ * Force a specific variant for an experiment. Persists immediately.
246
+ */
247
+ setExperiment(name, variant) {
248
+ experiments[name] = variant;
249
+ saveExperiments();
250
+ },
251
+ /**
252
+ * Reset all experiment assignments. Persists immediately.
253
+ */
254
+ clearExperiments() {
255
+ experiments = {};
256
+ saveExperiments();
257
+ },
258
+ /**
259
+ * Start a tracked operation. The `metric` slug should contain only lowercase letters,
260
+ * numbers, and hyphens (e.g. "photo-conversion", "api-request"). Invalid characters
261
+ * are auto-corrected with a warning logged in debug mode.
262
+ */
263
+ startOperation(metric, attrs) {
264
+ return new Operation(log, normalizeSlug(metric), attrs);
265
+ },
266
+ /**
267
+ * Record a single-shot metric. The `metric` slug should contain only lowercase letters,
268
+ * numbers, and hyphens (e.g. "onboarding", "checkout"). Invalid characters are
269
+ * auto-corrected with a warning logged in debug mode.
270
+ */
271
+ recordMetric(metric, attrs) {
272
+ log("info", `metric:${normalizeSlug(metric)}:record`, attrs);
273
+ },
274
+ withUser(userId) {
275
+ return new ScopedOwl(userId);
276
+ },
277
+ async flush() {
278
+ if (transport)
279
+ await transport.flush();
280
+ },
281
+ wrapHandler(handler) {
282
+ return async (...args) => {
283
+ try {
284
+ return await handler(...args);
285
+ }
286
+ finally {
287
+ await Owl.flush();
288
+ }
289
+ };
290
+ },
291
+ async shutdown() {
292
+ if (transport) {
293
+ await transport.shutdown();
294
+ transport = null;
295
+ }
296
+ config = null;
297
+ sessionId = null;
298
+ },
299
+ };
@@ -0,0 +1,19 @@
1
+ export type LogFn = (level: "info" | "error", message: string, attrs?: Record<string, unknown>, userId?: string) => void;
2
+ /**
3
+ * Tracks a metric operation lifecycle (start → complete/fail/cancel).
4
+ * Created by `Owl.startOperation()` or `ScopedOwl.startOperation()`.
5
+ */
6
+ export declare class Operation {
7
+ readonly trackingId: string;
8
+ private metric;
9
+ private startTime;
10
+ private userId?;
11
+ private log;
12
+ constructor(log: LogFn, metric: string, attrs?: Record<string, unknown>, userId?: string);
13
+ /** Complete the operation successfully. Auto-adds duration_ms. */
14
+ complete(attrs?: Record<string, unknown>): void;
15
+ /** Record a failed operation. Auto-adds duration_ms + error. */
16
+ fail(error: string, attrs?: Record<string, unknown>): void;
17
+ /** Cancel the operation. Auto-adds duration_ms. */
18
+ cancel(attrs?: Record<string, unknown>): void;
19
+ }
@@ -0,0 +1,49 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * Tracks a metric operation lifecycle (start → complete/fail/cancel).
4
+ * Created by `Owl.startOperation()` or `ScopedOwl.startOperation()`.
5
+ */
6
+ export class Operation {
7
+ trackingId;
8
+ metric;
9
+ startTime;
10
+ userId;
11
+ log;
12
+ constructor(log, metric, attrs, userId) {
13
+ this.trackingId = randomUUID();
14
+ this.metric = metric;
15
+ this.startTime = Date.now();
16
+ this.userId = userId;
17
+ this.log = log;
18
+ const startAttrs = { ...attrs, tracking_id: this.trackingId };
19
+ this.log("info", `metric:${metric}:start`, startAttrs, userId);
20
+ }
21
+ /** Complete the operation successfully. Auto-adds duration_ms. */
22
+ complete(attrs) {
23
+ const combined = {
24
+ ...attrs,
25
+ tracking_id: this.trackingId,
26
+ duration_ms: String(Date.now() - this.startTime),
27
+ };
28
+ this.log("info", `metric:${this.metric}:complete`, combined, this.userId);
29
+ }
30
+ /** Record a failed operation. Auto-adds duration_ms + error. */
31
+ fail(error, attrs) {
32
+ const combined = {
33
+ ...attrs,
34
+ tracking_id: this.trackingId,
35
+ duration_ms: String(Date.now() - this.startTime),
36
+ error,
37
+ };
38
+ this.log("error", `metric:${this.metric}:fail`, combined, this.userId);
39
+ }
40
+ /** Cancel the operation. Auto-adds duration_ms. */
41
+ cancel(attrs) {
42
+ const combined = {
43
+ ...attrs,
44
+ tracking_id: this.trackingId,
45
+ duration_ms: String(Date.now() - this.startTime),
46
+ };
47
+ this.log("info", `metric:${this.metric}:cancel`, combined, this.userId);
48
+ }
49
+ }
@@ -0,0 +1,14 @@
1
+ import type { ValidatedConfig } from "./configuration.js";
2
+ import type { LogEvent } from "./types.js";
3
+ export declare class Transport {
4
+ private buffer;
5
+ private timer;
6
+ private config;
7
+ private flushing;
8
+ constructor(config: ValidatedConfig);
9
+ enqueue(event: LogEvent): void;
10
+ flush(): Promise<void>;
11
+ shutdown(): Promise<void>;
12
+ get bufferSize(): number;
13
+ private sendBatch;
14
+ }
@@ -0,0 +1,111 @@
1
+ import { gzipSync } from "node:zlib";
2
+ const GZIP_THRESHOLD = 512;
3
+ const MAX_BATCH_SIZE = 20;
4
+ const MAX_RETRIES = 5;
5
+ const MAX_BACKOFF_MS = 30000;
6
+ const REQUEST_TIMEOUT_MS = 10000;
7
+ export class Transport {
8
+ buffer = [];
9
+ timer = null;
10
+ config;
11
+ flushing = false;
12
+ constructor(config) {
13
+ this.config = config;
14
+ this.timer = setInterval(() => this.flush(), config.flushIntervalMs);
15
+ // Prevent timer from keeping the process alive
16
+ if (this.timer.unref) {
17
+ this.timer.unref();
18
+ }
19
+ }
20
+ enqueue(event) {
21
+ if (this.buffer.length >= this.config.maxBufferSize) {
22
+ // Drop oldest events
23
+ this.buffer.shift();
24
+ }
25
+ this.buffer.push(event);
26
+ if (this.buffer.length >= this.config.flushThreshold) {
27
+ this.flush();
28
+ }
29
+ }
30
+ async flush() {
31
+ if (this.buffer.length === 0 || this.flushing)
32
+ return;
33
+ this.flushing = true;
34
+ try {
35
+ while (this.buffer.length > 0) {
36
+ const batch = this.buffer.splice(0, MAX_BATCH_SIZE);
37
+ await this.sendBatch(batch);
38
+ }
39
+ }
40
+ finally {
41
+ this.flushing = false;
42
+ }
43
+ }
44
+ async shutdown() {
45
+ if (this.timer) {
46
+ clearInterval(this.timer);
47
+ this.timer = null;
48
+ }
49
+ await this.flush();
50
+ }
51
+ get bufferSize() {
52
+ return this.buffer.length;
53
+ }
54
+ async sendBatch(events) {
55
+ const body = { events };
56
+ const json = JSON.stringify(body);
57
+ let payload;
58
+ let contentEncoding;
59
+ if (json.length > GZIP_THRESHOLD) {
60
+ payload = new Uint8Array(gzipSync(json));
61
+ contentEncoding = "gzip";
62
+ }
63
+ else {
64
+ payload = json;
65
+ }
66
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
67
+ try {
68
+ const headers = {
69
+ "Content-Type": "application/json",
70
+ "Authorization": `Bearer ${this.config.apiKey}`,
71
+ };
72
+ if (contentEncoding) {
73
+ headers["Content-Encoding"] = contentEncoding;
74
+ }
75
+ const res = await fetch(`${this.config.endpoint}/v1/ingest`, {
76
+ method: "POST",
77
+ headers,
78
+ body: payload,
79
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
80
+ });
81
+ if (res.ok)
82
+ return;
83
+ // Don't retry client errors (except 429)
84
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
85
+ if (this.config.debug) {
86
+ const text = await res.text().catch(() => "");
87
+ console.error(`OwlMetry: ingest failed with ${res.status}: ${text}`);
88
+ }
89
+ return;
90
+ }
91
+ // Server error or 429 — retry with backoff
92
+ if (attempt < MAX_RETRIES) {
93
+ const backoff = Math.min(Math.pow(2, attempt) * 1000, MAX_BACKOFF_MS);
94
+ await new Promise((r) => setTimeout(r, backoff));
95
+ }
96
+ }
97
+ catch (err) {
98
+ if (this.config.debug) {
99
+ console.error("OwlMetry: network error during ingest", err);
100
+ }
101
+ if (attempt < MAX_RETRIES) {
102
+ const backoff = Math.min(Math.pow(2, attempt) * 1000, MAX_BACKOFF_MS);
103
+ await new Promise((r) => setTimeout(r, backoff));
104
+ }
105
+ }
106
+ }
107
+ if (this.config.debug) {
108
+ console.error(`OwlMetry: failed to send batch after ${MAX_RETRIES + 1} attempts, dropping ${events.length} events`);
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,46 @@
1
+ export type LogLevel = "info" | "debug" | "warn" | "error";
2
+ export interface OwlConfiguration {
3
+ /** OwlMetry server endpoint URL */
4
+ endpoint: string;
5
+ /** Client API key for a server-platform app (must start with owl_client_) */
6
+ apiKey: string;
7
+ /** Service name for logging/debugging (not sent as bundle_id) */
8
+ serviceName?: string;
9
+ /** Application version */
10
+ appVersion?: string;
11
+ /** Enable debug logging to console.error */
12
+ debug?: boolean;
13
+ /** Flush interval in milliseconds (default: 5000) */
14
+ flushIntervalMs?: number;
15
+ /** Max events to buffer before auto-flush (default: 20) */
16
+ flushThreshold?: number;
17
+ /** Max events in buffer before dropping oldest (default: 10000) */
18
+ maxBufferSize?: number;
19
+ /** Mark events as development builds. Defaults to `process.env.NODE_ENV !== "production"` */
20
+ isDev?: boolean;
21
+ }
22
+ export interface LogEvent {
23
+ client_event_id: string;
24
+ session_id: string;
25
+ user_id?: string;
26
+ level: LogLevel;
27
+ source_module?: string;
28
+ message: string;
29
+ custom_attributes?: Record<string, string>;
30
+ experiments?: Record<string, string>;
31
+ environment: "backend";
32
+ app_version?: string;
33
+ is_dev?: boolean;
34
+ timestamp: string;
35
+ }
36
+ export interface IngestRequest {
37
+ events: LogEvent[];
38
+ }
39
+ export interface IngestResponse {
40
+ accepted: number;
41
+ rejected: number;
42
+ errors?: Array<{
43
+ index: number;
44
+ message: string;
45
+ }>;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@owlmetry/node",
3
+ "version": "0.1.0",
4
+ "description": "OwlMetry Node.js Server SDK",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist/src"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "node --test tests/unit/*.test.js",
20
+ "test:integration": "node --test tests/integration/*.test.js"
21
+ },
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/Jasonvdb/owlmetry.git",
29
+ "directory": "sdks/node"
30
+ },
31
+ "homepage": "https://owlmetry.com",
32
+ "bugs": "https://github.com/Jasonvdb/owlmetry/issues",
33
+ "keywords": [
34
+ "owlmetry",
35
+ "analytics",
36
+ "metrics",
37
+ "funnels",
38
+ "logging",
39
+ "observability",
40
+ "node",
41
+ "sdk"
42
+ ],
43
+ "devDependencies": {
44
+ "@types/node": "^25.5.0",
45
+ "typescript": "^5.9.3"
46
+ }
47
+ }