autotel-cloudflare 2.19.0 → 3.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.
@@ -1,336 +1,269 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base';
2
3
  import {
3
4
  createOtelObservability,
4
5
  createOtelObservabilityFromEnv,
5
6
  OtelObservability,
6
7
  } from './otel-observability';
7
- import type { AgentObservabilityEvent, MCPObservabilityEvent } from './types';
8
+ import { channels, genericObservability, subscribe } from './observability';
9
+ import type { ObservabilityEvent } from './types';
8
10
 
9
- // Helper to create mock events
10
- function createRpcEvent(method: string, streaming = false): AgentObservabilityEvent {
11
- return {
12
- type: 'rpc',
13
- id: `rpc-${Date.now()}`,
14
- displayMessage: `RPC call: ${method}`,
15
- payload: { method, streaming },
16
- timestamp: Date.now(),
17
- };
18
- }
11
+ type CapturingProcessor = SpanProcessor & { spans: ReadableSpan[] };
19
12
 
20
- function createConnectEvent(connectionId: string): AgentObservabilityEvent {
13
+ function createCapturingProcessor(): CapturingProcessor {
21
14
  return {
22
- type: 'connect',
23
- id: `connect-${Date.now()}`,
24
- displayMessage: `Connection: ${connectionId}`,
25
- payload: { connectionId },
26
- timestamp: Date.now(),
15
+ spans: [],
16
+ forceFlush: vi.fn(async () => undefined),
17
+ onEnd(span) {
18
+ this.spans.push(span);
19
+ },
20
+ onStart: vi.fn(),
21
+ shutdown: vi.fn(async () => undefined),
27
22
  };
28
23
  }
29
24
 
30
- function createScheduleEvent(
31
- eventType: 'schedule:create' | 'schedule:execute' | 'schedule:cancel',
32
- callback: string,
33
- id: string,
34
- ): AgentObservabilityEvent {
25
+ function createObservabilityEvent<T extends ObservabilityEvent['type']>(
26
+ type: T,
27
+ payload: Extract<ObservabilityEvent, { type: T }>['payload'],
28
+ extra: Partial<Extract<ObservabilityEvent, { type: T }>> = {},
29
+ ): Extract<ObservabilityEvent, { type: T }> {
35
30
  return {
36
- type: eventType,
37
- id: `schedule-${Date.now()}`,
38
- displayMessage: `Schedule ${eventType}: ${callback}`,
39
- payload: { callback, id },
31
+ type,
32
+ payload,
40
33
  timestamp: Date.now(),
41
- };
42
- }
43
-
44
- function createMcpConnectEvent(
45
- url: string,
46
- transport: string,
47
- state: string,
48
- ): MCPObservabilityEvent {
49
- return {
50
- type: 'mcp:client:connect',
51
- id: `mcp-${Date.now()}`,
52
- displayMessage: `MCP connect: ${url}`,
53
- payload: { url, transport, state },
54
- timestamp: Date.now(),
55
- };
34
+ ...extra,
35
+ } as Extract<ObservabilityEvent, { type: T }>;
56
36
  }
57
37
 
58
38
  describe('OtelObservability', () => {
39
+ const processor = createCapturingProcessor();
40
+
59
41
  beforeEach(() => {
60
42
  vi.clearAllMocks();
61
- // Prevent real fetch calls during span export — avoids floating promise rejections
62
- // that cause EnvironmentTeardownError when vitest tears down the worker environment.
63
- vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
43
+ processor.spans.length = 0;
64
44
  });
65
45
 
66
46
  afterEach(() => {
67
47
  vi.restoreAllMocks();
68
48
  });
69
49
 
70
- describe('createOtelObservability', () => {
71
- it('should create an OtelObservability instance', () => {
72
- const obs = createOtelObservability({
73
- service: { name: 'test-agent' },
74
- exporter: { url: 'http://localhost:4318/v1/traces' },
75
- });
76
-
77
- expect(obs).toBeInstanceOf(OtelObservability);
78
- });
79
-
80
- it('should accept custom agent options', () => {
50
+ describe('constructors', () => {
51
+ it('creates an OtelObservability instance', () => {
81
52
  const obs = createOtelObservability({
82
53
  service: { name: 'test-agent' },
83
- exporter: { url: 'http://localhost:4318/v1/traces' },
84
- agents: {
85
- traceRpc: true,
86
- traceSchedule: false,
87
- traceMcp: true,
88
- },
54
+ spanProcessors: [processor],
89
55
  });
90
56
 
91
57
  expect(obs).toBeInstanceOf(OtelObservability);
92
58
  });
93
- });
94
59
 
95
- describe('createOtelObservabilityFromEnv', () => {
96
- it('should create instance from environment variables', () => {
97
- const env = {
60
+ it('creates an instance from OTEL_* env vars', () => {
61
+ const obs = createOtelObservabilityFromEnv({
98
62
  OTEL_SERVICE_NAME: 'my-agent',
99
- OTEL_EXPORTER_OTLP_ENDPOINT: 'https://api.honeycomb.io',
100
- OTEL_EXPORTER_OTLP_HEADERS: 'x-honeycomb-team=test-key',
101
- };
102
-
103
- const obs = createOtelObservabilityFromEnv(env);
104
-
105
- expect(obs).toBeInstanceOf(OtelObservability);
106
- });
107
-
108
- it('should use defaults when env vars not set', () => {
109
- const obs = createOtelObservabilityFromEnv({});
110
-
111
- expect(obs).toBeInstanceOf(OtelObservability);
112
- });
113
-
114
- it('should parse multiple headers', () => {
115
- const env = {
116
- OTEL_EXPORTER_OTLP_HEADERS: 'key1=value1,key2=value2,key3=value3',
117
- };
118
-
119
- const obs = createOtelObservabilityFromEnv(env);
63
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example/v1/traces',
64
+ OTEL_EXPORTER_OTLP_HEADERS: 'x-api-key=test,key2=value2',
65
+ });
120
66
 
121
67
  expect(obs).toBeInstanceOf(OtelObservability);
122
68
  });
123
69
  });
124
70
 
125
71
  describe('emit', () => {
126
- it('should emit RPC events', () => {
72
+ it('records rpc spans with agent metadata', () => {
127
73
  const obs = createOtelObservability({
128
74
  service: { name: 'test-agent' },
129
- exporter: { url: 'http://localhost:4318/v1/traces' },
75
+ spanProcessors: [processor],
130
76
  });
131
77
 
132
- const event = createRpcEvent('doSomething', false);
133
-
134
- // Should not throw
135
- expect(() => obs.emit(event)).not.toThrow();
78
+ obs.emit(
79
+ createObservabilityEvent(
80
+ 'rpc',
81
+ { method: 'doSomething', streaming: true },
82
+ {
83
+ id: 'rpc-1',
84
+ agent: 'TaskAgent',
85
+ name: 'agent-instance-1',
86
+ displayMessage: 'RPC doSomething',
87
+ },
88
+ ),
89
+ );
90
+
91
+ expect(processor.spans).toHaveLength(1);
92
+ expect(processor.spans[0]?.name).toBe('agent.rpc doSomething');
93
+ expect(processor.spans[0]?.attributes).toMatchObject({
94
+ 'agent.event.type': 'rpc',
95
+ 'agent.event.id': 'rpc-1',
96
+ 'agent.class': 'TaskAgent',
97
+ 'agent.instance.name': 'agent-instance-1',
98
+ 'gen_ai.agent.name': 'TaskAgent',
99
+ 'agent.rpc.method': 'doSomething',
100
+ 'agent.rpc.streaming': true,
101
+ });
136
102
  });
137
103
 
138
- it('should emit connect events', () => {
104
+ it('records chat recovery events from the full agent event surface', () => {
139
105
  const obs = createOtelObservability({
140
106
  service: { name: 'test-agent' },
141
- exporter: { url: 'http://localhost:4318/v1/traces' },
107
+ spanProcessors: [processor],
142
108
  });
143
109
 
144
- const event = createConnectEvent('conn-123');
145
-
146
- expect(() => obs.emit(event)).not.toThrow();
110
+ obs.emit(
111
+ createObservabilityEvent('chat:recovery:detected', {
112
+ incidentId: 'inc-1',
113
+ requestId: 'req-1',
114
+ attempt: 2,
115
+ maxAttempts: 5,
116
+ recoveryKind: 'retry',
117
+ }),
118
+ );
119
+
120
+ expect(processor.spans).toHaveLength(1);
121
+ expect(processor.spans[0]?.name).toBe('agent.chat.recovery.detected');
122
+ expect(processor.spans[0]?.attributes).toMatchObject({
123
+ 'agent.event.type': 'chat:recovery:detected',
124
+ 'agent.payload.incidentId': 'inc-1',
125
+ 'agent.payload.requestId': 'req-1',
126
+ 'agent.payload.attempt': 2,
127
+ 'agent.payload.maxAttempts': 5,
128
+ 'agent.payload.recoveryKind': 'retry',
129
+ });
147
130
  });
148
131
 
149
- it('should emit schedule events', () => {
132
+ it('marks error spans when the event signals failure', () => {
150
133
  const obs = createOtelObservability({
151
134
  service: { name: 'test-agent' },
152
- exporter: { url: 'http://localhost:4318/v1/traces' },
135
+ spanProcessors: [processor],
153
136
  });
154
137
 
155
- const event = createScheduleEvent('schedule:create', 'myCallback', 'schedule-123');
156
-
157
- expect(() => obs.emit(event)).not.toThrow();
138
+ obs.emit(
139
+ createObservabilityEvent('rpc:error', {
140
+ method: 'doSomething',
141
+ error: 'boom',
142
+ }),
143
+ );
144
+
145
+ expect(processor.spans).toHaveLength(1);
146
+ expect(processor.spans[0]?.status.code).toBe(2);
147
+ expect(processor.spans[0]?.status.message).toBe('boom');
148
+ expect(processor.spans[0]?.attributes).toMatchObject({
149
+ error: true,
150
+ 'exception.message': 'boom',
151
+ });
158
152
  });
159
153
 
160
- it('should emit MCP events', () => {
154
+ it('records MCP close events from the reference event model', () => {
161
155
  const obs = createOtelObservability({
162
156
  service: { name: 'test-agent' },
163
- exporter: { url: 'http://localhost:4318/v1/traces' },
157
+ spanProcessors: [processor],
164
158
  });
165
159
 
166
- const event = createMcpConnectEvent('http://mcp-server.local', 'stdio', 'connected');
167
-
168
- expect(() => obs.emit(event)).not.toThrow();
160
+ obs.emit(
161
+ createObservabilityEvent('mcp:client:close', {
162
+ url: 'https://mcp.example',
163
+ transport: 'http',
164
+ state: 'closed',
165
+ phase: 'client-close',
166
+ }),
167
+ );
168
+
169
+ expect(processor.spans).toHaveLength(1);
170
+ expect(processor.spans[0]?.name).toBe('mcp.client.close https://mcp.example');
171
+ expect(processor.spans[0]?.attributes).toMatchObject({
172
+ 'agent.mcp.url': 'https://mcp.example',
173
+ 'agent.mcp.transport': 'http',
174
+ 'agent.mcp.state': 'closed',
175
+ 'agent.payload.phase': 'client-close',
176
+ });
169
177
  });
170
178
 
171
- it('should respect traceStateUpdates option', () => {
179
+ it('allows noisy categories to be disabled', () => {
172
180
  const obs = createOtelObservability({
173
181
  service: { name: 'test-agent' },
174
- exporter: { url: 'http://localhost:4318/v1/traces' },
182
+ spanProcessors: [processor],
175
183
  agents: {
176
184
  traceStateUpdates: false,
177
185
  },
178
186
  });
179
187
 
180
- const event: AgentObservabilityEvent = {
181
- type: 'state:update',
182
- id: 'state-123',
183
- displayMessage: 'State updated',
184
- payload: {},
185
- timestamp: Date.now(),
186
- };
188
+ obs.emit(createObservabilityEvent('state:update', {}));
187
189
 
188
- // Should not throw and should be a no-op due to traceStateUpdates: false
189
- expect(() => obs.emit(event)).not.toThrow();
190
+ expect(processor.spans).toHaveLength(0);
190
191
  });
191
192
 
192
- it('should use custom spanNameFormatter', () => {
193
- const customFormatter = vi.fn((event) => `custom-${event.type}`);
193
+ it('uses custom formatters and attribute extractors', () => {
194
+ const spanNameFormatter = vi.fn(() => 'custom.span');
195
+ const attributeExtractor = vi.fn(() => ({
196
+ 'custom.attr': 'value',
197
+ }));
194
198
 
195
199
  const obs = createOtelObservability({
196
200
  service: { name: 'test-agent' },
197
- exporter: { url: 'http://localhost:4318/v1/traces' },
201
+ spanProcessors: [processor],
198
202
  agents: {
199
- spanNameFormatter: customFormatter,
203
+ spanNameFormatter,
204
+ attributeExtractor,
200
205
  },
201
206
  });
202
207
 
203
- const event = createRpcEvent('testMethod');
204
- obs.emit(event);
205
-
206
- expect(customFormatter).toHaveBeenCalledWith(event);
207
- });
208
-
209
- it('should use custom attributeExtractor', () => {
210
- const customExtractor = vi.fn(() => ({ 'custom.attr': 'value' }));
211
-
212
- const obs = createOtelObservability({
213
- service: { name: 'test-agent' },
214
- exporter: { url: 'http://localhost:4318/v1/traces' },
215
- agents: {
216
- attributeExtractor: customExtractor,
217
- },
208
+ const event = createObservabilityEvent('workflow:approved', {
209
+ workflowId: 'wf-1',
210
+ reason: 'approved',
218
211
  });
219
212
 
220
- const event = createRpcEvent('testMethod');
221
213
  obs.emit(event);
222
214
 
223
- expect(customExtractor).toHaveBeenCalledWith(event);
215
+ expect(spanNameFormatter).toHaveBeenCalledWith(event);
216
+ expect(attributeExtractor).toHaveBeenCalledWith(event);
217
+ expect(processor.spans[0]?.name).toBe('custom.span');
218
+ expect(processor.spans[0]?.attributes['custom.attr']).toBe('value');
224
219
  });
225
220
 
226
- it('should emit with DurableObjectState context', () => {
221
+ it('uses waitUntil when an execution context is provided', () => {
227
222
  const obs = createOtelObservability({
228
223
  service: { name: 'test-agent' },
229
- exporter: { url: 'http://localhost:4318/v1/traces' },
224
+ spanProcessors: [processor],
230
225
  });
226
+ const waitUntil = vi.fn();
231
227
 
232
- const mockCtx = {
233
- waitUntil: vi.fn(),
234
- } as unknown as DurableObjectState;
235
-
236
- const event = createRpcEvent('testMethod');
228
+ obs.emit(
229
+ createObservabilityEvent('queue:create', {
230
+ callback: 'process',
231
+ id: 'queue-1',
232
+ }),
233
+ { waitUntil } as unknown as ExecutionContext,
234
+ );
237
235
 
238
- expect(() => obs.emit(event, mockCtx)).not.toThrow();
236
+ expect(waitUntil).toHaveBeenCalledTimes(1);
239
237
  });
240
238
  });
241
239
 
242
- describe('event types', () => {
243
- const obs = createOtelObservability({
244
- service: { name: 'test-agent' },
245
- exporter: { url: 'http://localhost:4318/v1/traces' },
246
- });
247
-
248
- it('should handle message:request events', () => {
249
- const event: AgentObservabilityEvent = {
250
- type: 'message:request',
251
- id: 'msg-req-123',
252
- displayMessage: 'Message request',
253
- payload: {},
254
- timestamp: Date.now(),
255
- };
256
-
257
- expect(() => obs.emit(event)).not.toThrow();
258
- });
259
-
260
- it('should handle message:response events', () => {
261
- const event: AgentObservabilityEvent = {
262
- type: 'message:response',
263
- id: 'msg-res-123',
264
- displayMessage: 'Message response',
265
- payload: {},
266
- timestamp: Date.now(),
267
- };
268
-
269
- expect(() => obs.emit(event)).not.toThrow();
270
- });
271
-
272
- it('should handle message:clear events', () => {
273
- const event: AgentObservabilityEvent = {
274
- type: 'message:clear',
275
- id: 'msg-clr-123',
276
- displayMessage: 'Messages cleared',
277
- payload: {},
278
- timestamp: Date.now(),
279
- };
240
+ describe('generic observability channels', () => {
241
+ it('routes events through typed subscriptions', () => {
242
+ const callback = vi.fn();
243
+ const unsubscribe = subscribe('rpc', callback);
244
+ const event = createObservabilityEvent('rpc', { method: 'lookup' });
280
245
 
281
- expect(() => obs.emit(event)).not.toThrow();
282
- });
283
-
284
- it('should handle destroy events', () => {
285
- const event: AgentObservabilityEvent = {
286
- type: 'destroy',
287
- id: 'destroy-123',
288
- displayMessage: 'Agent destroyed',
289
- payload: {},
290
- timestamp: Date.now(),
291
- };
292
-
293
- expect(() => obs.emit(event)).not.toThrow();
294
- });
246
+ genericObservability.emit(event);
295
247
 
296
- it('should handle mcp:client:preconnect events', () => {
297
- const event: MCPObservabilityEvent = {
298
- type: 'mcp:client:preconnect',
299
- id: 'mcp-pre-123',
300
- displayMessage: 'MCP preconnect',
301
- payload: { serverId: 'server-1' },
302
- timestamp: Date.now(),
303
- };
248
+ expect(callback).toHaveBeenCalledTimes(1);
249
+ expect(callback).toHaveBeenCalledWith(event);
304
250
 
305
- expect(() => obs.emit(event)).not.toThrow();
251
+ unsubscribe();
252
+ genericObservability.emit(event);
253
+ expect(callback).toHaveBeenCalledTimes(1);
306
254
  });
307
255
 
308
- it('should handle mcp:client:authorize events', () => {
309
- const event: MCPObservabilityEvent = {
310
- type: 'mcp:client:authorize',
311
- id: 'mcp-auth-123',
312
- displayMessage: 'MCP authorize',
313
- payload: {
314
- serverId: 'server-1',
315
- authUrl: 'https://auth.example.com',
316
- clientId: 'client-123',
317
- },
318
- timestamp: Date.now(),
319
- };
320
-
321
- expect(() => obs.emit(event)).not.toThrow();
322
- });
256
+ it('publishes directly on exported channels', () => {
257
+ const callback = vi.fn();
258
+ const unsubscribe = subscribe('mcp', callback);
259
+ const event = createObservabilityEvent('mcp:client:preconnect', {
260
+ serverId: 'server-1',
261
+ });
323
262
 
324
- it('should handle mcp:client:discover events', () => {
325
- const event: MCPObservabilityEvent = {
326
- type: 'mcp:client:discover',
327
- id: 'mcp-disc-123',
328
- displayMessage: 'MCP discover',
329
- payload: {},
330
- timestamp: Date.now(),
331
- };
263
+ channels.mcp.publish(event);
332
264
 
333
- expect(() => obs.emit(event)).not.toThrow();
265
+ expect(callback).toHaveBeenCalledWith(event);
266
+ unsubscribe();
334
267
  });
335
268
  });
336
269
  });