autotel-subscribers 40.0.0 → 41.0.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/src/factories.ts DELETED
@@ -1,342 +0,0 @@
1
- /**
2
- * Factory functions for creating events subscribers
3
- *
4
- * Function-based alternatives to `new SubscriberClass()` pattern.
5
- * Provides a consistent API and better tree-shaking.
6
- *
7
- * @example
8
- * ```typescript
9
- * import { createPostHogSubscriber, createWebhookSubscriber } from 'autotel-subscribers/factories'
10
- *
11
- * const events = new Events('my-service', {
12
- * subscribers: [
13
- * createPostHogSubscriber({ apiKey: 'phc_...' }),
14
- * createWebhookSubscriber({ url: 'https://...' })
15
- * ]
16
- * })
17
- * ```
18
- */
19
-
20
- import { PostHogSubscriber } from './posthog';
21
- import { MixpanelSubscriber } from './mixpanel';
22
- import { AmplitudeSubscriber } from './amplitude';
23
- import { SegmentSubscriber } from './segment';
24
- import { WebhookSubscriber } from './webhook';
25
- import { SlackSubscriber } from './slack';
26
- import { MockEventSubscriber } from './mock-event-subscriber';
27
-
28
- import type {
29
- EventSubscriber,
30
- EventAttributes,
31
- OutcomeStatus,
32
- FunnelStatus,
33
- EventTrackingOptions,
34
- } from 'autotel/event-subscriber';
35
-
36
- export type { PostHogConfig } from './posthog';
37
- export type { MixpanelConfig } from './mixpanel';
38
- export type { AmplitudeConfig } from './amplitude';
39
- export type { SegmentConfig } from './segment';
40
- export type { WebhookConfig } from './webhook';
41
- export type { SlackSubscriberConfig } from './slack';
42
-
43
- /** Create a PostHog events subscriber */
44
- export function createPostHogSubscriber(config: {
45
- apiKey: string;
46
- host?: string;
47
- enabled?: boolean;
48
- }): EventSubscriber {
49
- return new PostHogSubscriber(config);
50
- }
51
-
52
- /** Create a Mixpanel events subscriber */
53
- export function createMixpanelSubscriber(config: {
54
- token: string;
55
- enabled?: boolean;
56
- }): EventSubscriber {
57
- return new MixpanelSubscriber(config);
58
- }
59
-
60
- /** Create an Amplitude events subscriber */
61
- export function createAmplitudeSubscriber(config: {
62
- apiKey: string;
63
- enabled?: boolean;
64
- }): EventSubscriber {
65
- return new AmplitudeSubscriber(config);
66
- }
67
-
68
- /** Create a Segment events subscriber */
69
- export function createSegmentSubscriber(config: {
70
- writeKey: string;
71
- enabled?: boolean;
72
- }): EventSubscriber {
73
- return new SegmentSubscriber(config);
74
- }
75
-
76
- /** Create a Webhook events subscriber with retry and timeout support */
77
- export function createWebhookSubscriber(config: {
78
- url: string;
79
- method?: 'POST' | 'PUT';
80
- headers?: Record<string, string>;
81
- enabled?: boolean;
82
- maxRetries?: number;
83
- timeoutMs?: number;
84
- retryDelayMs?: number;
85
- }): EventSubscriber {
86
- return new WebhookSubscriber(config);
87
- }
88
-
89
- /** Create a Slack events subscriber */
90
- export function createSlackSubscriber(config: {
91
- webhookUrl: string;
92
- channel?: string;
93
- enabled?: boolean;
94
- }): EventSubscriber {
95
- return new SlackSubscriber(config);
96
- }
97
-
98
- /** Create a mock events subscriber for testing */
99
- export function createMockSubscriber(): MockEventSubscriber {
100
- return new MockEventSubscriber();
101
- }
102
-
103
- /**
104
- * Strategy for composing multiple subscribers
105
- *
106
- * - `parallel`: Send to all subscribers concurrently, fail if any fails
107
- * - `failover`: Try subscribers in order until one succeeds
108
- * - `round-robin`: Cycle through subscribers sequentially
109
- * - `random`: Pick a random subscriber each time
110
- * - `race`: Send to all, succeed when any succeeds
111
- * - `mirrored`: Send to primary, mirror to others (primary failure fails all)
112
- */
113
- export type ComposeSubscriberStrategy =
114
- | 'parallel'
115
- | 'failover'
116
- | 'round-robin'
117
- | 'random'
118
- | 'race'
119
- | 'mirrored';
120
-
121
- /** Configuration options for composing multiple subscribers */
122
- export type ComposeSubscribersOptions = {
123
- name?: string;
124
- strategy?: ComposeSubscriberStrategy;
125
- maxAttemptsPerSubscriber?: number;
126
- initialRetryDelayMs?: number;
127
- maxRetryDelayMs?: number;
128
- isRetriable?: (error: unknown) => boolean;
129
- logger?: Pick<Console, 'debug' | 'warn' | 'error'>;
130
- };
131
-
132
- type SubscriberMethod =
133
- | 'trackEvent'
134
- | 'trackFunnelStep'
135
- | 'trackOutcome'
136
- | 'trackValue';
137
-
138
- type MethodCall = {
139
- method: SubscriberMethod;
140
- args: unknown[];
141
- };
142
-
143
- function backoffDelay(attempt: number, initialMs: number, maxMs: number): number {
144
- return Math.min(maxMs, initialMs * 2 ** (attempt - 1));
145
- }
146
-
147
- async function callSubscriber(subscriber: EventSubscriber, call: MethodCall): Promise<void> {
148
- switch (call.method) {
149
- case 'trackEvent': {
150
- await subscriber.trackEvent(
151
- call.args[0] as string,
152
- call.args[1] as EventAttributes | undefined,
153
- call.args[2] as EventTrackingOptions | undefined,
154
- );
155
- return;
156
- }
157
- case 'trackFunnelStep': {
158
- await subscriber.trackFunnelStep(
159
- call.args[0] as string,
160
- call.args[1] as FunnelStatus,
161
- call.args[2] as EventAttributes | undefined,
162
- call.args[3] as EventTrackingOptions | undefined,
163
- );
164
- return;
165
- }
166
- case 'trackOutcome': {
167
- await subscriber.trackOutcome(
168
- call.args[0] as string,
169
- call.args[1] as OutcomeStatus,
170
- call.args[2] as EventAttributes | undefined,
171
- call.args[3] as EventTrackingOptions | undefined,
172
- );
173
- return;
174
- }
175
- case 'trackValue': {
176
- await subscriber.trackValue(
177
- call.args[0] as string,
178
- call.args[1] as number,
179
- call.args[2] as EventAttributes | undefined,
180
- call.args[3] as EventTrackingOptions | undefined,
181
- );
182
- }
183
- }
184
- }
185
-
186
- /**
187
- * Compose multiple subscribers into one with a specified strategy
188
- *
189
- * @example
190
- * ```typescript
191
- * const multiSubscriber = composeSubscribers(
192
- * [
193
- * createPostHogSubscriber({ apiKey: '...' }),
194
- * createWebhookSubscriber({ url: '...' })
195
- * ],
196
- * { strategy: 'parallel' }
197
- * )
198
- * ```
199
- *
200
- * @param subscribers - Array of subscribers to compose
201
- * @param options - Configuration for composition strategy and retry behavior
202
- * @returns A composite EventSubscriber that applies the strategy
203
- */
204
- export function composeSubscribers(
205
- subscribers: EventSubscriber[],
206
- options: ComposeSubscribersOptions = {},
207
- ): EventSubscriber {
208
- const strategy = options.strategy ?? 'parallel';
209
- const name = options.name ?? `ComposedSubscriber(${strategy})`;
210
- const maxAttempts = options.maxAttemptsPerSubscriber ?? 1;
211
- const initialRetryDelayMs = options.initialRetryDelayMs ?? 250;
212
- const maxRetryDelayMs = options.maxRetryDelayMs ?? 10_000;
213
- const isRetriable = options.isRetriable ?? (() => true);
214
- const logger = options.logger ?? console;
215
- let rrCounter = 0;
216
-
217
- const sendOne = async (subscriber: EventSubscriber, call: MethodCall): Promise<void> => {
218
- let lastError: unknown;
219
-
220
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
221
- try {
222
- await callSubscriber(subscriber, call);
223
- return;
224
- } catch (error) {
225
- lastError = error;
226
- const retryable = isRetriable(error);
227
- logger.warn?.('composeSubscribers attempt failed', {
228
- subscriber: subscriber.name,
229
- attempt,
230
- retryable,
231
- strategy,
232
- error,
233
- });
234
-
235
- if (!retryable || attempt >= maxAttempts) break;
236
- await new Promise((resolve) =>
237
- setTimeout(resolve, backoffDelay(attempt, initialRetryDelayMs, maxRetryDelayMs)),
238
- );
239
- }
240
- }
241
-
242
- throw (lastError instanceof Error ? lastError : new Error(String(lastError)));
243
- };
244
-
245
- const orderForSequential = (): number[] => {
246
- const n = subscribers.length;
247
- if (n === 0) return [];
248
-
249
- if (strategy === 'failover') {
250
- return Array.from({ length: n }, (_, i) => i);
251
- }
252
-
253
- const start =
254
- strategy === 'random' ? Math.floor(Math.random() * n) : rrCounter++ % n;
255
-
256
- return Array.from({ length: n }, (_, i) => (start + i) % n);
257
- };
258
-
259
- const execute = async (call: MethodCall): Promise<void> => {
260
- if (subscribers.length === 0) return;
261
-
262
- if (strategy === 'parallel') {
263
- await Promise.all(subscribers.map((subscriber) => sendOne(subscriber, call)));
264
- return;
265
- }
266
-
267
- if (strategy === 'race') {
268
- const attempts = subscribers.map(async (subscriber) => {
269
- await sendOne(subscriber, call);
270
- return subscriber.name ?? 'unknown';
271
- });
272
-
273
- try {
274
- await Promise.any(attempts);
275
- } catch (error) {
276
- if (error instanceof AggregateError && error.errors.length > 0) {
277
- throw error.errors.at(-1);
278
- }
279
- throw error;
280
- }
281
- return;
282
- }
283
-
284
- if (strategy === 'mirrored') {
285
- const primary = subscribers[0];
286
- if (!primary) return;
287
- await sendOne(primary, call);
288
-
289
- for (const mirror of subscribers.slice(1)) {
290
- void sendOne(mirror, call).catch((error) => {
291
- logger.warn?.('composeSubscribers mirror failed', {
292
- subscriber: mirror.name,
293
- error,
294
- });
295
- });
296
- }
297
- return;
298
- }
299
-
300
- const order = orderForSequential();
301
- let lastError: unknown;
302
-
303
- for (const index of order) {
304
- const subscriber = subscribers[index];
305
- if (!subscriber) continue;
306
-
307
- try {
308
- await sendOne(subscriber, call);
309
- return;
310
- } catch (error) {
311
- lastError = error;
312
- }
313
- }
314
-
315
- throw (lastError instanceof Error ? lastError : new Error(String(lastError)));
316
- };
317
-
318
- return {
319
- name,
320
- async trackEvent(name_, attributes, options_) {
321
- await execute({ method: 'trackEvent', args: [name_, attributes, options_] });
322
- },
323
- async trackFunnelStep(funnel, step, attributes, options_) {
324
- await execute({
325
- method: 'trackFunnelStep',
326
- args: [funnel, step, attributes, options_],
327
- });
328
- },
329
- async trackOutcome(operation, outcome, attributes, options_) {
330
- await execute({
331
- method: 'trackOutcome',
332
- args: [operation, outcome, attributes, options_],
333
- });
334
- },
335
- async trackValue(name_, value, attributes, options_) {
336
- await execute({ method: 'trackValue', args: [name_, value, attributes, options_] });
337
- },
338
- async shutdown() {
339
- await Promise.all(subscribers.map(async (subscriber) => subscriber.shutdown?.()));
340
- },
341
- };
342
- }
package/src/file.test.ts DELETED
@@ -1,87 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import nodePath from 'node:path';
5
- import { FileSubscriber } from './file';
6
-
7
- describe('FileSubscriber', () => {
8
- let dir: string;
9
-
10
- beforeEach(async () => {
11
- dir = await mkdtemp(nodePath.join(tmpdir(), 'autotel-file-sub-'));
12
- });
13
-
14
- afterEach(async () => {
15
- await rm(dir, { recursive: true, force: true });
16
- });
17
-
18
- it('appends one NDJSON line per event', async () => {
19
- const path = nodePath.join(dir, 'events.ndjson');
20
- const sub = new FileSubscriber({ path });
21
-
22
- await sub.trackEvent('order.completed', { amount: 99 });
23
- await sub.trackEvent('order.refunded', { amount: 99 });
24
- await sub.shutdown();
25
-
26
- const contents = await readFile(path, 'utf8');
27
- const lines = contents.trim().split('\n');
28
- expect(lines).toHaveLength(2);
29
-
30
- const first = JSON.parse(lines[0]);
31
- expect(first.name).toBe('order.completed');
32
- expect(first.type).toBe('event');
33
- expect(first.attributes).toEqual({ amount: 99 });
34
-
35
- const second = JSON.parse(lines[1]);
36
- expect(second.name).toBe('order.refunded');
37
- });
38
-
39
- it('creates parent directories when missing', async () => {
40
- const path = nodePath.join(dir, 'nested', 'deep', 'events.ndjson');
41
- const sub = new FileSubscriber({ path });
42
-
43
- await sub.trackEvent('created');
44
- await sub.shutdown();
45
-
46
- const contents = await readFile(path, 'utf8');
47
- expect(JSON.parse(contents.trim()).name).toBe('created');
48
- });
49
-
50
- it('supports pretty multi-line output', async () => {
51
- const path = nodePath.join(dir, 'pretty.json');
52
- const sub = new FileSubscriber({ path, pretty: true });
53
-
54
- await sub.trackEvent('pretty.event', { nested: { a: 1 } });
55
- await sub.shutdown();
56
-
57
- const contents = await readFile(path, 'utf8');
58
- expect(contents).toContain('\n "name": "pretty.event"');
59
- });
60
-
61
- it('skips events when transform returns null', async () => {
62
- const path = nodePath.join(dir, 'filtered.ndjson');
63
- const sub = new FileSubscriber({
64
- path,
65
- transform: (p) => (p.name === 'keep' ? { kept: p.name } : null),
66
- });
67
-
68
- await sub.trackEvent('drop');
69
- await sub.trackEvent('keep');
70
- await sub.shutdown();
71
-
72
- const contents = await readFile(path, 'utf8');
73
- const lines = contents.trim().split('\n');
74
- expect(lines).toHaveLength(1);
75
- expect(JSON.parse(lines[0])).toEqual({ kept: 'keep' });
76
- });
77
-
78
- it('writes nothing when disabled', async () => {
79
- const path = nodePath.join(dir, 'disabled.ndjson');
80
- const sub = new FileSubscriber({ path, enabled: false });
81
-
82
- await sub.trackEvent('ignored');
83
- await sub.shutdown();
84
-
85
- await expect(readFile(path, 'utf8')).rejects.toThrow();
86
- });
87
- });
package/src/file.ts DELETED
@@ -1,97 +0,0 @@
1
- /**
2
- * File subscriber for autotel.
3
- *
4
- * Appends each tracked event to a file as newline-delimited JSON (NDJSON).
5
- * Useful for AI agents, scripts, evals, and local debugging that want
6
- * structured events on disk without a hosted backend. Query the file with
7
- * `jq`, load it into a notebook, or feed it to an agent.
8
- *
9
- * @example
10
- * ```typescript
11
- * import { Event } from 'autotel/events';
12
- * import { FileSubscriber } from 'autotel-subscribers/file';
13
- *
14
- * const events = new Event('worker', {
15
- * subscribers: [new FileSubscriber({ path: './telemetry/events.ndjson' })],
16
- * });
17
- * ```
18
- */
19
-
20
- import { appendFile, mkdir } from 'node:fs/promises';
21
- import path from 'node:path';
22
- import { EventSubscriber, type EventPayload } from './event-subscriber-base';
23
-
24
- export interface FileSubscriberConfig {
25
- /** File path to append newline-delimited JSON events to. */
26
- path: string;
27
- /** Enable or disable the subscriber. Default `true`. */
28
- enabled?: boolean;
29
- /** Pretty-print each event as indented JSON instead of one line. Default `false`. */
30
- pretty?: boolean;
31
- /** Create parent directories if they do not exist. Default `true`. */
32
- mkdir?: boolean;
33
- /**
34
- * Transform a payload before writing. Return `null` to skip the event.
35
- * Defaults to writing the normalized payload unchanged.
36
- */
37
- transform?: (payload: EventPayload) => Record<string, unknown> | null;
38
- }
39
-
40
- export class FileSubscriber extends EventSubscriber {
41
- readonly name = 'FileSubscriber';
42
- readonly version = '1.0.0';
43
-
44
- private readonly filePath: string;
45
- private readonly pretty: boolean;
46
- private readonly ensureDir: boolean;
47
- private readonly transform?: (
48
- payload: EventPayload,
49
- ) => Record<string, unknown> | null;
50
-
51
- /** Serializes writes so concurrent events never interleave on disk. */
52
- private writeChain: Promise<void> = Promise.resolve();
53
- private dirEnsured = false;
54
-
55
- constructor(config: FileSubscriberConfig) {
56
- super();
57
- this.filePath = config.path;
58
- this.enabled = config.enabled ?? true;
59
- this.pretty = config.pretty ?? false;
60
- this.ensureDir = config.mkdir ?? true;
61
- this.transform = config.transform;
62
- }
63
-
64
- protected async sendToDestination(payload: EventPayload): Promise<void> {
65
- if (!this.enabled) return;
66
-
67
- const record = this.transform ? this.transform(payload) : payload;
68
- if (record === null) return;
69
-
70
- const json = this.pretty
71
- ? JSON.stringify(record, null, 2)
72
- : JSON.stringify(record);
73
- const line = `${json}\n`;
74
-
75
- const run = this.writeChain.then(() => this.write(line));
76
- // Keep the chain ordered and alive even if one write rejects; the failed
77
- // write still rejects `run` so the base class can report it.
78
- this.writeChain = run.catch(() => {});
79
- await run;
80
- }
81
-
82
- private async write(line: string): Promise<void> {
83
- if (this.ensureDir && !this.dirEnsured) {
84
- const dir = path.dirname(this.filePath);
85
- if (dir && dir !== '.') {
86
- await mkdir(dir, { recursive: true });
87
- }
88
- this.dirEnsured = true;
89
- }
90
- await appendFile(this.filePath, line, 'utf8');
91
- }
92
-
93
- override async shutdown(): Promise<void> {
94
- await this.writeChain;
95
- await super.shutdown();
96
- }
97
- }
@@ -1,127 +0,0 @@
1
- /**
2
- * HTTP client for sending webhook events
3
- *
4
- * Provides proper error handling, timeout support, and response parsing
5
- */
6
-
7
- export type HttpRetryOptions = {
8
- retries?: number;
9
- delayMs?: number;
10
- };
11
-
12
- export type HttpClientOptions = {
13
- timeoutMs?: number;
14
- retry?: HttpRetryOptions;
15
- };
16
-
17
- export type HttpSuccess<T = unknown> = {
18
- ok: true;
19
- status: number;
20
- data: T | null;
21
- };
22
-
23
- export type HttpNetworkError = {
24
- ok: false;
25
- kind: 'network';
26
- timedOut: boolean;
27
- cause: Error;
28
- };
29
-
30
- export type HttpStatusError<E = unknown> = {
31
- ok: false;
32
- kind: 'http';
33
- status: number;
34
- statusText: string;
35
- body: E;
36
- };
37
-
38
- export type HttpResult<T = unknown, E = unknown> =
39
- | HttpSuccess<T>
40
- | HttpNetworkError
41
- | HttpStatusError<E>;
42
-
43
- export type HttpRequestOptions = {
44
- method?: string;
45
- headers?: Record<string, string>;
46
- body?: string;
47
- timeoutMs?: number;
48
- };
49
-
50
- async function parseBody(response: Response): Promise<unknown> {
51
- const text = await response.text();
52
- if (text.trim().length === 0) return null;
53
-
54
- try {
55
- return JSON.parse(text) as unknown;
56
- } catch {
57
- return text;
58
- }
59
- }
60
-
61
- function isTimeoutError(error: unknown): boolean {
62
- if (!(error instanceof Error)) return false;
63
- return error.name === 'AbortError' || error.name === 'TimeoutError';
64
- }
65
-
66
- /**
67
- * Create an HTTP client with timeout and error handling
68
- *
69
- * @param options Configuration for timeout and retry behavior
70
- * @returns HTTP client with request method
71
- *
72
- * @example
73
- * ```typescript
74
- * const client = createHttpClient({ timeoutMs: 5000 })
75
- * const result = await client.request('https://example.com', {
76
- * method: 'POST',
77
- * body: JSON.stringify({ event: 'test' })
78
- * })
79
- * ```
80
- */
81
- export function createHttpClient(options: HttpClientOptions = {}) {
82
- const defaultTimeoutMs = options.timeoutMs ?? 30_000;
83
-
84
- return {
85
- async request<T = unknown, E = unknown>(
86
- url: string,
87
- requestOptions: HttpRequestOptions = {},
88
- ): Promise<HttpResult<T, E>> {
89
- const timeoutMs = requestOptions.timeoutMs ?? defaultTimeoutMs;
90
- const controller = new AbortController();
91
- const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
92
-
93
- try {
94
- const response = await fetch(url, {
95
- method: requestOptions.method ?? 'GET',
96
- headers: requestOptions.headers,
97
- body: requestOptions.body,
98
- signal: controller.signal,
99
- });
100
-
101
- if (!response.ok) {
102
- const body = (await parseBody(response)) as E;
103
- return {
104
- ok: false,
105
- kind: 'http',
106
- status: response.status,
107
- statusText: response.statusText,
108
- body,
109
- };
110
- }
111
-
112
- const data = (await parseBody(response)) as T;
113
- return { ok: true, status: response.status, data };
114
- } catch (error) {
115
- const cause = error instanceof Error ? error : new Error(String(error));
116
- return {
117
- ok: false,
118
- kind: 'network',
119
- timedOut: isTimeoutError(error),
120
- cause,
121
- };
122
- } finally {
123
- clearTimeout(timeoutHandle);
124
- }
125
- },
126
- };
127
- }