autotel-edge 3.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +333 -0
  3. package/dist/chunk-F32WSLNX.js +309 -0
  4. package/dist/chunk-F32WSLNX.js.map +1 -0
  5. package/dist/events.d.ts +86 -0
  6. package/dist/events.js +157 -0
  7. package/dist/events.js.map +1 -0
  8. package/dist/index.d.ts +326 -0
  9. package/dist/index.js +921 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/logger.d.ts +89 -0
  12. package/dist/logger.js +81 -0
  13. package/dist/logger.js.map +1 -0
  14. package/dist/sampling.d.ts +166 -0
  15. package/dist/sampling.js +108 -0
  16. package/dist/sampling.js.map +1 -0
  17. package/dist/testing.d.ts +2 -0
  18. package/dist/testing.js +3 -0
  19. package/dist/testing.js.map +1 -0
  20. package/dist/types-Dj85cPUj.d.ts +182 -0
  21. package/package.json +88 -0
  22. package/src/api/logger.test.ts +367 -0
  23. package/src/api/logger.ts +197 -0
  24. package/src/compose.ts +243 -0
  25. package/src/core/buffer.ts +16 -0
  26. package/src/core/config.test.ts +388 -0
  27. package/src/core/config.ts +167 -0
  28. package/src/core/context.ts +224 -0
  29. package/src/core/exporter.ts +99 -0
  30. package/src/core/provider.ts +45 -0
  31. package/src/core/span.ts +222 -0
  32. package/src/core/spanprocessor.test.ts +521 -0
  33. package/src/core/spanprocessor.ts +232 -0
  34. package/src/core/trace-context.ts +66 -0
  35. package/src/core/tracer.test.ts +123 -0
  36. package/src/core/tracer.ts +216 -0
  37. package/src/events/index.test.ts +242 -0
  38. package/src/events/index.ts +338 -0
  39. package/src/events.ts +6 -0
  40. package/src/functional.test.ts +702 -0
  41. package/src/functional.ts +846 -0
  42. package/src/index.ts +81 -0
  43. package/src/logger.ts +13 -0
  44. package/src/sampling/index.test.ts +297 -0
  45. package/src/sampling/index.ts +276 -0
  46. package/src/sampling.ts +6 -0
  47. package/src/testing/index.ts +9 -0
  48. package/src/testing.ts +6 -0
  49. package/src/types.ts +267 -0
@@ -0,0 +1,388 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { parseConfig, createInitialiser, getActiveConfig, setConfig } from './config';
3
+ import type { EdgeConfig } from '../types';
4
+ import { context as api_context } from '@opentelemetry/api';
5
+
6
+ describe('Config System', () => {
7
+ describe('parseConfig()', () => {
8
+ it('should parse minimal config (only service.name)', () => {
9
+ const config: EdgeConfig = {
10
+ service: { name: 'test-service' },
11
+ };
12
+
13
+ const parsed = parseConfig(config);
14
+
15
+ expect(parsed.service.name).toBe('test-service');
16
+ expect(parsed.service.version).toBeUndefined();
17
+ expect(parsed.service.namespace).toBeUndefined();
18
+ });
19
+
20
+ it('should parse full service config', () => {
21
+ const config: EdgeConfig = {
22
+ service: {
23
+ name: 'test-service',
24
+ version: '1.2.3',
25
+ namespace: 'production',
26
+ },
27
+ };
28
+
29
+ const parsed = parseConfig(config);
30
+
31
+ expect(parsed.service.name).toBe('test-service');
32
+ expect(parsed.service.version).toBe('1.2.3');
33
+ expect(parsed.service.namespace).toBe('production');
34
+ });
35
+
36
+ it('should parse exporter config (URL + headers)', () => {
37
+ const config: EdgeConfig = {
38
+ service: { name: 'test-service' },
39
+ exporter: {
40
+ url: 'https://api.honeycomb.io/v1/traces',
41
+ headers: { 'x-api-key': 'test-key' },
42
+ },
43
+ };
44
+
45
+ const parsed = parseConfig(config);
46
+
47
+ expect(parsed.spanProcessors).toHaveLength(1);
48
+ expect(parsed.spanProcessors[0]).toBeDefined();
49
+ });
50
+
51
+ it('should create SpanProcessor from exporter', () => {
52
+ const config: EdgeConfig = {
53
+ service: { name: 'test-service' },
54
+ exporter: {
55
+ url: 'http://localhost:4318/v1/traces',
56
+ },
57
+ };
58
+
59
+ const parsed = parseConfig(config);
60
+
61
+ expect(parsed.spanProcessors).toBeDefined();
62
+ expect(Array.isArray(parsed.spanProcessors)).toBe(true);
63
+ expect(parsed.spanProcessors.length).toBeGreaterThan(0);
64
+ });
65
+
66
+ it('should accept custom SpanProcessor array', () => {
67
+ const mockSpanProcessor = {
68
+ onStart: () => {},
69
+ onEnd: () => {},
70
+ forceFlush: async () => {},
71
+ shutdown: async () => {},
72
+ };
73
+
74
+ const config: EdgeConfig = {
75
+ service: { name: 'test-service' },
76
+ spanProcessors: [mockSpanProcessor as any],
77
+ };
78
+
79
+ const parsed = parseConfig(config);
80
+
81
+ expect(parsed.spanProcessors).toHaveLength(1);
82
+ expect(parsed.spanProcessors[0]).toBe(mockSpanProcessor);
83
+ });
84
+
85
+ it('should use AlwaysOnSampler as default head sampler', () => {
86
+ const config: EdgeConfig = {
87
+ service: { name: 'test-service' },
88
+ };
89
+
90
+ const parsed = parseConfig(config);
91
+
92
+ expect(parsed.sampling.headSampler).toBeDefined();
93
+ // Default is ParentBasedSampler with AlwaysOnSampler root
94
+ expect(parsed.sampling.headSampler.toString()).toContain('ParentBased');
95
+ });
96
+
97
+ it('should create ParentRatioSampler from ratio config', () => {
98
+ const config: EdgeConfig = {
99
+ service: { name: 'test-service' },
100
+ sampling: {
101
+ headSampler: {
102
+ ratio: 0.5,
103
+ acceptRemote: true,
104
+ },
105
+ },
106
+ };
107
+
108
+ const parsed = parseConfig(config);
109
+
110
+ expect(parsed.sampling.headSampler).toBeDefined();
111
+ // Should be ParentBasedSampler wrapping ratio sampler
112
+ expect(parsed.sampling.headSampler.toString()).toContain('ParentBased');
113
+ });
114
+
115
+ it('should use default tail sampler (keep sampled or errors)', () => {
116
+ const config: EdgeConfig = {
117
+ service: { name: 'test-service' },
118
+ };
119
+
120
+ const parsed = parseConfig(config);
121
+
122
+ expect(parsed.sampling.tailSampler).toBeDefined();
123
+ expect(typeof parsed.sampling.tailSampler).toBe('function');
124
+ });
125
+
126
+ it('should accept custom tail sampler', () => {
127
+ const customTailSampler = () => true;
128
+
129
+ const config: EdgeConfig = {
130
+ service: { name: 'test-service' },
131
+ sampling: {
132
+ tailSampler: customTailSampler,
133
+ },
134
+ };
135
+
136
+ const parsed = parseConfig(config);
137
+
138
+ expect(parsed.sampling.tailSampler).toBe(customTailSampler);
139
+ });
140
+
141
+ it('should use W3CTraceContextPropagator as default', () => {
142
+ const config: EdgeConfig = {
143
+ service: { name: 'test-service' },
144
+ };
145
+
146
+ const parsed = parseConfig(config);
147
+
148
+ expect(parsed.propagator).toBeDefined();
149
+ expect(parsed.propagator.constructor.name).toBe('W3CTraceContextPropagator');
150
+ });
151
+
152
+ it('should accept custom propagator', () => {
153
+ const mockPropagator = {
154
+ inject: () => {},
155
+ extract: () => ({} as any),
156
+ fields: () => [],
157
+ };
158
+
159
+ const config: EdgeConfig = {
160
+ service: { name: 'test-service' },
161
+ propagator: mockPropagator as any,
162
+ };
163
+
164
+ const parsed = parseConfig(config);
165
+
166
+ expect(parsed.propagator).toBe(mockPropagator);
167
+ });
168
+
169
+ it('should enable global fetch instrumentation by default', () => {
170
+ const config: EdgeConfig = {
171
+ service: { name: 'test-service' },
172
+ };
173
+
174
+ const parsed = parseConfig(config);
175
+
176
+ expect(parsed.instrumentation.instrumentGlobalFetch).toBe(true);
177
+ });
178
+
179
+ it('should disable global cache instrumentation by default', () => {
180
+ const config: EdgeConfig = {
181
+ service: { name: 'test-service' },
182
+ };
183
+
184
+ const parsed = parseConfig(config);
185
+
186
+ expect(parsed.instrumentation.instrumentGlobalCache).toBe(false);
187
+ });
188
+
189
+ it('should allow enabling cache instrumentation', () => {
190
+ const config: EdgeConfig = {
191
+ service: { name: 'test-service' },
192
+ instrumentation: {
193
+ instrumentGlobalCache: true,
194
+ },
195
+ };
196
+
197
+ const parsed = parseConfig(config);
198
+
199
+ expect(parsed.instrumentation.instrumentGlobalCache).toBe(true);
200
+ });
201
+
202
+ it('should use default fetch.includeTraceContext = true', () => {
203
+ const config: EdgeConfig = {
204
+ service: { name: 'test-service' },
205
+ };
206
+
207
+ const parsed = parseConfig(config);
208
+
209
+ expect(parsed.fetch.includeTraceContext).toBe(true);
210
+ });
211
+
212
+ it('should accept custom fetch.includeTraceContext function', () => {
213
+ const customFn = (request: Request) => request.url.includes('internal');
214
+
215
+ const config: EdgeConfig = {
216
+ service: { name: 'test-service' },
217
+ fetch: {
218
+ includeTraceContext: customFn,
219
+ },
220
+ };
221
+
222
+ const parsed = parseConfig(config);
223
+
224
+ expect(parsed.fetch.includeTraceContext).toBe(customFn);
225
+ });
226
+ });
227
+
228
+ describe('createInitialiser()', () => {
229
+ it('should create initialiser from static config', () => {
230
+ const config: EdgeConfig = {
231
+ service: { name: 'test-service' },
232
+ };
233
+
234
+ const initialiser = createInitialiser(config);
235
+
236
+ expect(typeof initialiser).toBe('function');
237
+
238
+ const resolved = initialiser({}, { request: null as any });
239
+
240
+ expect(resolved.service.name).toBe('test-service');
241
+ });
242
+
243
+ it('should create initialiser from config function', () => {
244
+ const configFn = (env: { SERVICE_NAME: string }) => ({
245
+ service: { name: env.SERVICE_NAME },
246
+ });
247
+
248
+ const initialiser = createInitialiser(configFn);
249
+
250
+ expect(typeof initialiser).toBe('function');
251
+
252
+ const resolved = initialiser({ SERVICE_NAME: 'dynamic-service' }, { request: null as any });
253
+
254
+ expect(resolved.service.name).toBe('dynamic-service');
255
+ });
256
+
257
+ it('should pass env and trigger to config function', () => {
258
+ const configFn = vi.fn((env: any, trigger: any) => ({
259
+ service: { name: 'test' },
260
+ }));
261
+
262
+ const initialiser = createInitialiser(configFn);
263
+
264
+ const mockEnv = { API_KEY: 'test-key' };
265
+ const mockTrigger = { request: new Request('http://example.com') };
266
+
267
+ initialiser(mockEnv, mockTrigger);
268
+
269
+ expect(configFn).toHaveBeenCalledWith(mockEnv, mockTrigger);
270
+ });
271
+ });
272
+
273
+ describe('getActiveConfig() / setConfig()', () => {
274
+ beforeEach(() => {
275
+ // Reset active config before each test
276
+ setConfig(null as any);
277
+ });
278
+
279
+ it('should store and retrieve active config', () => {
280
+ const config: EdgeConfig = {
281
+ service: { name: 'test-service' },
282
+ };
283
+
284
+ const parsed = parseConfig(config);
285
+ const ctx = setConfig(parsed);
286
+
287
+ // Use api_context.with() to activate the context
288
+ api_context.with(ctx, () => {
289
+ const active = getActiveConfig();
290
+ expect(active).toBe(parsed);
291
+ expect(active?.service.name).toBe('test-service');
292
+ });
293
+ });
294
+
295
+ it('should return null when no active config', () => {
296
+ const ctx = setConfig(null as any);
297
+
298
+ api_context.with(ctx, () => {
299
+ const active = getActiveConfig();
300
+ expect(active).toBeNull();
301
+ });
302
+ });
303
+
304
+ it('should allow updating active config', () => {
305
+ const config1 = parseConfig({
306
+ service: { name: 'service-1' },
307
+ });
308
+
309
+ const ctx1 = setConfig(config1);
310
+ api_context.with(ctx1, () => {
311
+ expect(getActiveConfig()?.service.name).toBe('service-1');
312
+ });
313
+
314
+ const config2 = parseConfig({
315
+ service: { name: 'service-2' },
316
+ });
317
+
318
+ const ctx2 = setConfig(config2);
319
+ api_context.with(ctx2, () => {
320
+ expect(getActiveConfig()?.service.name).toBe('service-2');
321
+ });
322
+ });
323
+ });
324
+
325
+ describe('Config context isolation', () => {
326
+ it('should isolate config per context using setConfig() return value', () => {
327
+ const config1 = parseConfig({
328
+ service: { name: 'service-1' },
329
+ });
330
+
331
+ const config2 = parseConfig({
332
+ service: { name: 'service-2' },
333
+ });
334
+
335
+ // Create separate contexts
336
+ const context1 = setConfig(config1);
337
+ const context2 = setConfig(config2);
338
+
339
+ // Verify contexts are different
340
+ expect(context1).not.toBe(context2);
341
+ });
342
+
343
+ it('should use OpenTelemetry context for storage', () => {
344
+ const config = parseConfig({
345
+ service: { name: 'test-service' },
346
+ });
347
+
348
+ const context = setConfig(config);
349
+
350
+ // setConfig should return a context object
351
+ expect(context).toBeDefined();
352
+ expect(typeof context).toBe('object');
353
+ });
354
+
355
+ it('should not have race conditions with context-based storage', () => {
356
+ // This test verifies the fix for the config race condition bug
357
+ // where module-level state caused request B to overwrite request A's config
358
+
359
+ const configA = parseConfig({
360
+ service: { name: 'request-a' },
361
+ });
362
+
363
+ const configB = parseConfig({
364
+ service: { name: 'request-b' },
365
+ });
366
+
367
+ // Simulate setting configs for different requests
368
+ const contextA = setConfig(configA);
369
+ const contextB = setConfig(configB);
370
+
371
+ // Both contexts should exist independently
372
+ expect(contextA).not.toBe(contextB);
373
+
374
+ // Each context has its own config that doesn't interfere with the other
375
+ api_context.with(contextA, () => {
376
+ const activeConfig = getActiveConfig();
377
+ expect(activeConfig?.service.name).toBe('request-a');
378
+ });
379
+
380
+ api_context.with(contextB, () => {
381
+ const activeConfig = getActiveConfig();
382
+ expect(activeConfig?.service.name).toBe('request-b');
383
+ });
384
+
385
+ // This demonstrates that configs are properly isolated per context
386
+ });
387
+ });
388
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Configuration system for autotel-edge
3
+ */
4
+
5
+ import { W3CTraceContextPropagator } from '@opentelemetry/core';
6
+ import { ParentBasedSampler, AlwaysOnSampler } from '@opentelemetry/sdk-trace-base';
7
+ import { context as api_context, createContextKey, type Context } from '@opentelemetry/api';
8
+ import type {
9
+ EdgeConfig,
10
+ ResolvedEdgeConfig,
11
+ ConfigurationOption,
12
+ Trigger,
13
+ ParentRatioSamplingConfig,
14
+ } from '../types';
15
+ import { isSpanProcessorConfig } from '../types';
16
+ import { OTLPExporter } from './exporter';
17
+ import { TailSamplingSpanProcessor } from './spanprocessor';
18
+
19
+ /**
20
+ * Type for config initialization function
21
+ */
22
+ export type Initialiser = (env: any, trigger: Trigger) => ResolvedEdgeConfig;
23
+
24
+ /**
25
+ * Context key for storing config (isolates config per-request)
26
+ */
27
+ const CONFIG_KEY = createContextKey('autotel-edge-config');
28
+
29
+ /**
30
+ * Get the currently active config from context
31
+ *
32
+ * This reads the config from the active context, ensuring each request
33
+ * has its own isolated config even when multiple requests are in-flight.
34
+ */
35
+ export function getActiveConfig(): ResolvedEdgeConfig | null {
36
+ const value = api_context.active().getValue(CONFIG_KEY) as
37
+ | ResolvedEdgeConfig
38
+ | null
39
+ | undefined;
40
+ return value ?? null;
41
+ }
42
+
43
+ /**
44
+ * Set the active config in context
45
+ *
46
+ * Returns a new context with the config stored. This context should be
47
+ * used with api_context.with() to ensure the config is isolated per-request.
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * const config = parseConfig({ service: { name: 'my-service' } });
52
+ * const context = setConfig(config);
53
+ *
54
+ * api_context.with(context, () => {
55
+ * // Config is available here via getActiveConfig()
56
+ * });
57
+ * ```
58
+ */
59
+ export function setConfig(config: ResolvedEdgeConfig): Context {
60
+ return api_context.active().setValue(CONFIG_KEY, config);
61
+ }
62
+
63
+ /**
64
+ * Parse and validate configuration
65
+ */
66
+ export function parseConfig(config: EdgeConfig): ResolvedEdgeConfig {
67
+ // Parse head sampler
68
+ const headSampler =
69
+ config.sampling?.headSampler ??
70
+ new ParentBasedSampler({
71
+ root: new AlwaysOnSampler(),
72
+ });
73
+
74
+ const parsedHeadSampler =
75
+ typeof headSampler === 'object' && 'ratio' in headSampler
76
+ ? createParentRatioSampler(headSampler)
77
+ : headSampler;
78
+
79
+ // Parse tail sampler (default: keep sampled or error traces)
80
+ const tailSampler =
81
+ config.sampling?.tailSampler ??
82
+ ((traceInfo) => {
83
+ const localRootSpan = traceInfo.localRootSpan;
84
+ const ctx = localRootSpan.spanContext();
85
+ // Keep if sampled or if root span has error
86
+ return (ctx.traceFlags & 1) === 1 || localRootSpan.status.code === 2; // SAMPLED flag | ERROR status
87
+ });
88
+
89
+ // Parse exporter - use TailSamplingSpanProcessor when tail sampler is present
90
+ const spanProcessors = isSpanProcessorConfig(config)
91
+ ? Array.isArray(config.spanProcessors)
92
+ ? config.spanProcessors
93
+ : [config.spanProcessors]
94
+ : [
95
+ // Use TailSamplingSpanProcessor to enable tail sampling
96
+ new TailSamplingSpanProcessor(
97
+ typeof config.exporter === 'object' && 'url' in config.exporter
98
+ ? new OTLPExporter(config.exporter)
99
+ : config.exporter,
100
+ config.postProcessor,
101
+ tailSampler, // Wire up the tail sampler!
102
+ ),
103
+ ];
104
+
105
+ // Build resolved config
106
+ const resolved: ResolvedEdgeConfig = {
107
+ service: config.service,
108
+ handlers: {
109
+ fetch: config.handlers?.fetch ?? {},
110
+ },
111
+ fetch: {
112
+ includeTraceContext: config.fetch?.includeTraceContext ?? true,
113
+ },
114
+ postProcessor: config.postProcessor ?? ((spans) => spans),
115
+ sampling: {
116
+ headSampler: parsedHeadSampler,
117
+ tailSampler,
118
+ },
119
+ spanProcessors,
120
+ propagator: config.propagator ?? new W3CTraceContextPropagator(),
121
+ instrumentation: {
122
+ instrumentGlobalFetch: config.instrumentation?.instrumentGlobalFetch ?? true,
123
+ instrumentGlobalCache: config.instrumentation?.instrumentGlobalCache ?? false,
124
+ disabled: config.instrumentation?.disabled ?? false,
125
+ },
126
+ subscribers: config.subscribers ?? [],
127
+ };
128
+
129
+ return resolved;
130
+ }
131
+
132
+ /**
133
+ * Create a parent-based ratio sampler
134
+ */
135
+ function createParentRatioSampler(config: ParentRatioSamplingConfig) {
136
+ const { ratio, acceptRemote = true } = config;
137
+
138
+ // Simple ratio sampler
139
+ const ratioSampler = {
140
+ shouldSample: () => ({
141
+ decision: Math.random() < ratio ? 1 : 0, // RECORD_AND_SAMPLED : NOT_RECORD
142
+ attributes: {},
143
+ }),
144
+ toString: () => `ParentRatioSampler{ratio=${ratio}}`,
145
+ };
146
+
147
+ if (acceptRemote) {
148
+ return new ParentBasedSampler({ root: ratioSampler as any });
149
+ }
150
+
151
+ return ratioSampler;
152
+ }
153
+
154
+ /**
155
+ * Create a config initializer function
156
+ */
157
+ export function createInitialiser(config: ConfigurationOption): Initialiser {
158
+ if (typeof config === 'function') {
159
+ return (env, trigger) => {
160
+ const conf = parseConfig(config(env, trigger));
161
+ return conf;
162
+ };
163
+ } else {
164
+ const parsed = parseConfig(config);
165
+ return () => parsed;
166
+ }
167
+ }