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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autotel-subscribers",
3
- "version": "40.0.0",
3
+ "version": "41.0.0",
4
4
  "description": "Write Once, Observe Anywhere - Event subscribers for autotel (PostHog, Mixpanel, Amplitude, Segment)",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -75,7 +75,6 @@
75
75
  },
76
76
  "files": [
77
77
  "dist",
78
- "src",
79
78
  "examples",
80
79
  "README.md",
81
80
  "skills"
@@ -101,7 +100,7 @@
101
100
  "slow-redact": "^0.3.2"
102
101
  },
103
102
  "peerDependencies": {
104
- "autotel": "4.1.0"
103
+ "autotel": "4.2.0"
105
104
  },
106
105
  "peerDependenciesMeta": {
107
106
  "posthog-node": {
@@ -138,7 +137,7 @@
138
137
  "typescript": "^6.0.3",
139
138
  "typescript-eslint": "^8.60.1",
140
139
  "vitest": "^4.1.8",
141
- "autotel": "4.1.0"
140
+ "autotel": "4.2.0"
142
141
  },
143
142
  "repository": {
144
143
  "type": "git",
@@ -1,229 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { AmplitudeSubscriber } from './amplitude';
3
-
4
- // Hoist mocks so they exist when vi.mock factory runs (factory is hoisted before other code)
5
- const mockTrack = vi.hoisted(() => vi.fn());
6
- const mockInit = vi.hoisted(() => vi.fn());
7
- const mockFlush = vi.hoisted(() => vi.fn(() => Promise.resolve()));
8
-
9
- vi.mock('@amplitude/analytics-node', () => ({
10
- init: mockInit,
11
- track: mockTrack,
12
- flush: mockFlush,
13
- }));
14
-
15
- // Prime the mocked module so the first dynamic import in AmplitudeSubscriber gets the mock (avoids CI race)
16
- import '@amplitude/analytics-node';
17
-
18
- describe('AmplitudeSubscriber', () => {
19
- beforeEach(() => {
20
- vi.clearAllMocks();
21
- mockTrack.mockClear();
22
- mockInit.mockClear();
23
- mockFlush.mockClear();
24
- });
25
-
26
- describe('initialization', () => {
27
- it('should initialize with valid config', async () => {
28
- const adapter = new AmplitudeSubscriber({
29
- apiKey: 'test_api_key',
30
- });
31
-
32
- await new Promise((resolve) => setTimeout(resolve, 100));
33
-
34
- expect(adapter).toBeDefined();
35
- });
36
-
37
- it('should not initialize when disabled', () => {
38
- const adapter = new AmplitudeSubscriber({
39
- apiKey: 'test_api_key',
40
- enabled: false,
41
- });
42
-
43
- expect(adapter).toBeDefined();
44
- });
45
- });
46
-
47
- describe('trackEvent', () => {
48
- it('should track event with attributes', async () => {
49
- const adapter = new AmplitudeSubscriber({
50
- apiKey: 'test_api_key',
51
- });
52
-
53
- await new Promise((resolve) => setTimeout(resolve, 100));
54
-
55
- await adapter.trackEvent('order.completed', {
56
- userId: 'user-123',
57
- amount: 99.99,
58
- });
59
-
60
- await new Promise((resolve) => setTimeout(resolve, 100));
61
-
62
- expect(mockTrack).toHaveBeenCalledWith({
63
- event_type: 'order.completed',
64
- user_id: 'user-123',
65
- event_properties: {
66
- userId: 'user-123',
67
- amount: 99.99,
68
- },
69
- });
70
- });
71
-
72
- it('should use user_id if userId is not present', async () => {
73
- const adapter = new AmplitudeSubscriber({
74
- apiKey: 'test_api_key',
75
- });
76
-
77
- await new Promise((resolve) => setTimeout(resolve, 100));
78
-
79
- await adapter.trackEvent('order.completed', {
80
- user_id: 'user-456',
81
- });
82
-
83
- await new Promise((resolve) => setTimeout(resolve, 100));
84
-
85
- expect(mockTrack).toHaveBeenCalledWith({
86
- event_type: 'order.completed',
87
- user_id: 'user-456',
88
- event_properties: {
89
- user_id: 'user-456',
90
- },
91
- });
92
- });
93
-
94
- it('should use anonymous if no userId is present', async () => {
95
- const adapter = new AmplitudeSubscriber({
96
- apiKey: 'test_api_key',
97
- });
98
-
99
- await new Promise((resolve) => setTimeout(resolve, 100));
100
-
101
- await adapter.trackEvent('page.viewed');
102
-
103
- await new Promise((resolve) => setTimeout(resolve, 100));
104
-
105
- expect(mockTrack).toHaveBeenCalledWith({
106
- event_type: 'page.viewed',
107
- user_id: 'anonymous',
108
- event_properties: undefined,
109
- });
110
- });
111
-
112
- it('should not track when disabled', () => {
113
- const adapter = new AmplitudeSubscriber({
114
- apiKey: 'test_api_key',
115
- enabled: false,
116
- });
117
-
118
- adapter.trackEvent('order.completed', { userId: 'user-123' });
119
-
120
- // Should not throw
121
- expect(true).toBe(true);
122
- });
123
- });
124
-
125
- describe('trackFunnelStep', () => {
126
- it('should track funnel step', async () => {
127
- const adapter = new AmplitudeSubscriber({
128
- apiKey: 'test_api_key',
129
- });
130
-
131
- await new Promise((resolve) => setTimeout(resolve, 100));
132
-
133
- await adapter.trackFunnelStep('checkout', 'started', {
134
- userId: 'user-123',
135
- cartValue: 150,
136
- });
137
-
138
- await new Promise((resolve) => setTimeout(resolve, 100));
139
-
140
- expect(mockTrack).toHaveBeenCalledWith({
141
- event_type: 'checkout.started',
142
- user_id: 'user-123',
143
- event_properties: {
144
- funnel: 'checkout',
145
- step: 'started',
146
- userId: 'user-123',
147
- cartValue: 150,
148
- },
149
- });
150
- });
151
- });
152
-
153
- describe('trackOutcome', () => {
154
- it('should track outcome', async () => {
155
- const adapter = new AmplitudeSubscriber({
156
- apiKey: 'test_api_key',
157
- });
158
-
159
- await new Promise((resolve) => setTimeout(resolve, 100));
160
-
161
- await adapter.trackOutcome('payment.processing', 'success', {
162
- userId: 'user-123',
163
- transactionId: 'txn-789',
164
- });
165
-
166
- await new Promise((resolve) => setTimeout(resolve, 100));
167
-
168
- expect(mockTrack).toHaveBeenCalledWith({
169
- event_type: 'payment.processing.success',
170
- user_id: 'user-123',
171
- event_properties: {
172
- operation: 'payment.processing',
173
- outcome: 'success',
174
- userId: 'user-123',
175
- transactionId: 'txn-789',
176
- },
177
- });
178
- });
179
- });
180
-
181
- describe('trackValue', () => {
182
- it('should track value', async () => {
183
- const adapter = new AmplitudeSubscriber({
184
- apiKey: 'test_api_key',
185
- });
186
-
187
- await new Promise((resolve) => setTimeout(resolve, 100));
188
-
189
- await adapter.trackValue('revenue', 99.99, {
190
- userId: 'user-123',
191
- currency: 'USD',
192
- });
193
-
194
- await new Promise((resolve) => setTimeout(resolve, 100));
195
-
196
- expect(mockTrack).toHaveBeenCalledWith({
197
- event_type: 'revenue',
198
- user_id: 'user-123',
199
- event_properties: {
200
- value: 99.99,
201
- userId: 'user-123',
202
- currency: 'USD',
203
- },
204
- });
205
- });
206
- });
207
-
208
- describe('shutdown', () => {
209
- it('should call flush on Amplitude instance', async () => {
210
- const adapter = new AmplitudeSubscriber({
211
- apiKey: 'test_api_key',
212
- });
213
-
214
- await new Promise((resolve) => setTimeout(resolve, 100));
215
- await adapter.shutdown();
216
-
217
- expect(mockFlush).toHaveBeenCalled();
218
- });
219
-
220
- it('should not throw when shutting down disabled adapter', async () => {
221
- const adapter = new AmplitudeSubscriber({
222
- apiKey: 'test_api_key',
223
- enabled: false,
224
- });
225
-
226
- await expect(adapter.shutdown()).resolves.not.toThrow();
227
- });
228
- });
229
- });
package/src/amplitude.ts DELETED
@@ -1,150 +0,0 @@
1
- /**
2
- * Amplitude Subscriber for autotel
3
- *
4
- * Send events to Amplitude for product events.
5
- *
6
- * @example
7
- * ```typescript
8
- * import { Events } from 'autotel/events';
9
- * import { AmplitudeSubscriber } from 'autotel-subscribers/amplitude';
10
- *
11
- * const events = new Events('checkout', {
12
- * subscribers: [
13
- * new AmplitudeSubscriber({
14
- * apiKey: process.env.AMPLITUDE_API_KEY!
15
- * })
16
- * ]
17
- * });
18
- *
19
- * events.trackEvent('order.completed', { userId: '123', amount: 99.99 });
20
- * ```
21
- */
22
-
23
- import type {
24
- EventSubscriber,
25
- EventAttributes,
26
- FunnelStatus,
27
- OutcomeStatus,
28
- } from 'autotel/event-subscriber';
29
-
30
- export interface AmplitudeConfig {
31
- /** Amplitude API key */
32
- apiKey: string;
33
- /** Enable/disable the subscriber */
34
- enabled?: boolean;
35
- }
36
-
37
- export class AmplitudeSubscriber implements EventSubscriber {
38
- readonly name = 'AmplitudeSubscriber';
39
- readonly version = '1.0.0';
40
-
41
- private amplitudeModule: any = null;
42
- private enabled: boolean;
43
- private config: AmplitudeConfig;
44
- private initPromise: Promise<void> | null = null;
45
-
46
- constructor(config: AmplitudeConfig) {
47
- this.enabled = config.enabled ?? true;
48
- this.config = config;
49
-
50
- if (this.enabled) {
51
- // Start initialization immediately but don't block constructor
52
- this.initPromise = this.initialize();
53
- }
54
- }
55
-
56
- private async initialize(): Promise<void> {
57
- try {
58
- // Dynamic import to avoid adding @amplitude/analytics-node as a hard dependency
59
- // The SDK exports init(), track(), flush() as separate functions
60
- const amplitude = await import('@amplitude/analytics-node');
61
- amplitude.init(this.config.apiKey);
62
- this.amplitudeModule = amplitude;
63
- } catch (error) {
64
- console.error(
65
- 'Amplitude subscriber failed to initialize. Install @amplitude/analytics-node: pnpm add @amplitude/analytics-node',
66
- error,
67
- );
68
- this.enabled = false;
69
- }
70
- }
71
-
72
- private async ensureInitialized(): Promise<void> {
73
- if (this.initPromise) {
74
- await this.initPromise;
75
- this.initPromise = null;
76
- }
77
- }
78
-
79
- async trackEvent(name: string, attributes?: EventAttributes): Promise<void> {
80
- if (!this.enabled) return;
81
-
82
- await this.ensureInitialized();
83
- this.amplitudeModule?.track({
84
- event_type: name,
85
- user_id: attributes?.userId || attributes?.user_id || 'anonymous',
86
- event_properties: attributes,
87
- });
88
- }
89
-
90
- async trackFunnelStep(
91
- funnelName: string,
92
- step: FunnelStatus,
93
- attributes?: EventAttributes,
94
- ): Promise<void> {
95
- if (!this.enabled) return;
96
-
97
- await this.ensureInitialized();
98
- this.amplitudeModule?.track({
99
- event_type: `${funnelName}.${step}`,
100
- user_id: attributes?.userId || attributes?.user_id || 'anonymous',
101
- event_properties: {
102
- funnel: funnelName,
103
- step,
104
- ...attributes,
105
- },
106
- });
107
- }
108
-
109
- async trackOutcome(
110
- operationName: string,
111
- outcome: OutcomeStatus,
112
- attributes?: EventAttributes,
113
- ): Promise<void> {
114
- if (!this.enabled) return;
115
-
116
- await this.ensureInitialized();
117
- this.amplitudeModule?.track({
118
- event_type: `${operationName}.${outcome}`,
119
- user_id: attributes?.userId || attributes?.user_id || 'anonymous',
120
- event_properties: {
121
- operation: operationName,
122
- outcome,
123
- ...attributes,
124
- },
125
- });
126
- }
127
-
128
- async trackValue(name: string, value: number, attributes?: EventAttributes): Promise<void> {
129
- if (!this.enabled) return;
130
-
131
- await this.ensureInitialized();
132
- this.amplitudeModule?.track({
133
- event_type: name,
134
- user_id: attributes?.userId || attributes?.user_id || 'anonymous',
135
- event_properties: {
136
- value,
137
- ...attributes,
138
- },
139
- });
140
- }
141
-
142
- /** Flush pending events before shutdown */
143
- async shutdown(): Promise<void> {
144
- await this.ensureInitialized();
145
- if (this.amplitudeModule) {
146
- await this.amplitudeModule.flush();
147
- }
148
- }
149
- }
150
-
@@ -1,220 +0,0 @@
1
- import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import path from 'node:path';
4
- import { beforeEach, describe, expect, it, vi } from 'vitest';
5
- import {
6
- ArchitectureSnapshotSubscriber,
7
- extractFieldPaths,
8
- ARCHITECTURE_SNAPSHOT_SPEC,
9
- } from './architecture-snapshot';
10
- import type { EventPayload } from './event-subscriber-base';
11
-
12
- const FIXED_NOW = () => new Date('2026-05-21T18:04:00.000Z');
13
-
14
- function event(
15
- name: string,
16
- attributes: Record<string, unknown> = {},
17
- options: { traceId?: string; at?: string } = {},
18
- ): EventPayload {
19
- return {
20
- type: 'event',
21
- name,
22
- attributes,
23
- timestamp: options.at ?? '2026-05-21T18:00:00.000Z',
24
- autotel: options.traceId
25
- ? { trace_id: options.traceId, correlation_id: 'corr-1' }
26
- : undefined,
27
- };
28
- }
29
-
30
- describe('extractFieldPaths', () => {
31
- it('collapses array items under `[]`', () => {
32
- expect(
33
- extractFieldPaths({
34
- items: [{ sku: 'a', quantity: 1 }, { sku: 'b' }],
35
- }),
36
- ).toEqual(['items', 'items[].quantity', 'items[].sku']);
37
- });
38
-
39
- it('returns empty for primitives at the root', () => {
40
- expect(extractFieldPaths('x')).toEqual([]);
41
- expect(extractFieldPaths(42)).toEqual([]);
42
- expect(extractFieldPaths(null)).toEqual([]);
43
- // eslint-disable-next-line unicorn/no-useless-undefined
44
- expect(extractFieldPaths(undefined)).toEqual([]);
45
- });
46
-
47
- it('handles nested objects', () => {
48
- expect(
49
- extractFieldPaths({ a: { b: { c: 1 } } }),
50
- ).toEqual(['a', 'a.b', 'a.b.c']);
51
- });
52
- });
53
-
54
- describe('ArchitectureSnapshotSubscriber', () => {
55
- let sub: ArchitectureSnapshotSubscriber;
56
-
57
- beforeEach(() => {
58
- sub = new ArchitectureSnapshotSubscriber({ service: 'orders' });
59
- });
60
-
61
- it('records a new event on first observation', async () => {
62
- await sub.trackEvent('order.placed', {
63
- orderId: 'o-1',
64
- items: [{ sku: 'sku-1', quantity: 2 }],
65
- });
66
-
67
- const snap = sub.toSnapshot({ now: FIXED_NOW });
68
- expect(snap.spec).toBe(ARCHITECTURE_SNAPSHOT_SPEC);
69
- expect(snap.service).toBe('orders');
70
- expect(snap.events['order.placed']).toMatchObject({
71
- name: 'order.placed',
72
- observedCount: 1,
73
- fieldPaths: ['items', 'items[].quantity', 'items[].sku', 'orderId'],
74
- fieldStats: {
75
- orderId: { types: ['string'], sampleValues: ['o-1'] },
76
- items: { types: ['array'], sampleValues: [] },
77
- 'items[].sku': { types: ['string'], sampleValues: ['sku-1'] },
78
- 'items[].quantity': { types: ['number'], sampleValues: [2] },
79
- },
80
- });
81
- });
82
-
83
- it('accumulates count, lastSeen, and merges field paths across calls', async () => {
84
- await sub.trackEvent('order.placed', { orderId: 'o-1' });
85
- // Second call adds a new field path.
86
- await sub.trackEvent('order.placed', {
87
- orderId: 'o-2',
88
- shipping: { addressId: 'addr_1' },
89
- });
90
-
91
- const obs = sub.toSnapshot({ now: FIXED_NOW }).events['order.placed'];
92
- expect(obs.observedCount).toBe(2);
93
- expect(obs.fieldPaths).toEqual([
94
- 'orderId',
95
- 'shipping',
96
- 'shipping.addressId',
97
- ]);
98
- });
99
-
100
- it('reads channel and producer from the _autotel namespace', async () => {
101
- await sub.trackEvent('order.placed', {
102
- orderId: 'o-1',
103
- _autotel: {
104
- channel: 'orders.events',
105
- producer: 'OrdersService',
106
- consumers: ['PaymentService'],
107
- },
108
- });
109
-
110
- const obs = sub.toSnapshot({ now: FIXED_NOW }).events['order.placed'];
111
- expect(obs.channel).toBe('orders.events');
112
- expect(obs.producer).toBe('OrdersService');
113
- expect(obs.consumers).toEqual(['PaymentService']);
114
- // _autotel must not leak into the captured field paths.
115
- expect(obs.fieldPaths).toEqual(['orderId']);
116
- });
117
-
118
- it('captures schema metadata passed at track() call sites', async () => {
119
- await sub.trackEvent(
120
- 'order.placed',
121
- { orderId: 'o-1' },
122
- {
123
- schema: {
124
- source: 'zod',
125
- jsonSchema: {
126
- type: 'object',
127
- properties: { orderId: { type: 'string' } },
128
- required: ['orderId'],
129
- },
130
- hash: 'abc123',
131
- },
132
- },
133
- );
134
-
135
- const obs = sub.toSnapshot({ now: FIXED_NOW }).events['order.placed'];
136
- expect(obs.schema).toEqual({
137
- source: 'zod',
138
- jsonSchema: {
139
- type: 'object',
140
- properties: { orderId: { type: 'string' } },
141
- required: ['orderId'],
142
- },
143
- hash: 'abc123',
144
- });
145
- });
146
-
147
- it('collects up to maxSampleTraceIds distinct trace ids', async () => {
148
- const limited = new ArchitectureSnapshotSubscriber({
149
- service: 'orders',
150
- maxSampleTraceIds: 2,
151
- });
152
- for (const id of ['t-1', 't-2', 't-3', 't-1']) {
153
- await (limited as unknown as { sendToDestination(p: EventPayload): Promise<void> })
154
- .sendToDestination(event('order.placed', {}, { traceId: id }));
155
- }
156
- const obs = limited.toSnapshot({ now: FIXED_NOW }).events['order.placed'];
157
- expect(obs.sampleTraceIds).toEqual(['t-1', 't-2']);
158
- });
159
-
160
- it('ignores non-event payload types', async () => {
161
- await sub.trackEvent('order.placed', { orderId: 'o-1' });
162
- await sub.trackOutcome('checkout', 'success');
163
- await sub.trackValue('revenue', 99);
164
-
165
- const snap = sub.toSnapshot({ now: FIXED_NOW });
166
- expect(Object.keys(snap.events)).toEqual(['order.placed']);
167
- });
168
-
169
- it('produces deterministic output for identical inputs', async () => {
170
- // Freeze the clock so the firstSeen/lastSeen timestamps assigned to
171
- // subscriber `b`'s events match subscriber `a`'s exactly. Without this
172
- // the two subscribers can land in different ms ticks and the byte-for-
173
- // byte JSON comparison flakes in CI.
174
- vi.useFakeTimers();
175
- vi.setSystemTime(new Date('2026-05-21T18:00:00.000Z'));
176
- try {
177
- const a = new ArchitectureSnapshotSubscriber({ service: 'orders' });
178
- const b = new ArchitectureSnapshotSubscriber({ service: 'orders' });
179
- const payload = { orderId: 'o-1', items: [{ sku: 'a' }, { sku: 'b' }] };
180
-
181
- await a.trackEvent('order.placed', payload);
182
- await a.trackEvent('payment.captured', { orderId: 'o-1' });
183
- // Same events, reversed order.
184
- await b.trackEvent('payment.captured', { orderId: 'o-1' });
185
- await b.trackEvent('order.placed', payload);
186
-
187
- const at = () => new Date('2026-05-21T18:04:00.000Z');
188
- expect(JSON.stringify(a.toSnapshot({ now: at }))).toBe(
189
- JSON.stringify(b.toSnapshot({ now: at })),
190
- );
191
- } finally {
192
- vi.useRealTimers();
193
- }
194
- });
195
-
196
- it('writeToFile creates parent dirs and writes JSON with trailing newline', async () => {
197
- const dir = await mkdtemp(path.join(tmpdir(), 'autotel-arch-snap-'));
198
- try {
199
- const target = path.join(dir, 'nested', 'snap.json');
200
- const fresh = new ArchitectureSnapshotSubscriber({ service: 'orders' });
201
- await fresh.trackEvent('order.placed', { orderId: 'o-1' });
202
- await fresh.writeToFile(target, { now: FIXED_NOW });
203
-
204
- const body = await readFile(target, 'utf8');
205
- expect(body.endsWith('\n')).toBe(true);
206
- expect(JSON.parse(body)).toMatchObject({
207
- spec: ARCHITECTURE_SNAPSHOT_SPEC,
208
- service: 'orders',
209
- });
210
- } finally {
211
- await rm(dir, { recursive: true, force: true });
212
- }
213
- });
214
-
215
- it('reset() clears all accumulated observations', async () => {
216
- await sub.trackEvent('order.placed', { orderId: 'o-1' });
217
- sub.reset();
218
- expect(sub.toSnapshot({ now: FIXED_NOW }).events).toEqual({});
219
- });
220
- });