autotel
Version:
Write Once, Observe Anywhere
409 lines (407 loc) • 12.2 kB
JavaScript
import { configure } from "./config.js";
import { assertEventTracked, assertOutcomeTracked, createEventCollector } from "./event-testing.js";
import { SpanStatusCode, context, trace } from "@opentelemetry/api";
//#region src/testing.ts
/**
* Testing Utilities
*
* Helpers for testing instrumented code and verifying telemetry.
* Perfect for integration tests and QA in production validation.
*
* @example Verify traces are created
* ```typescript
* import { assertTraceCreated, collectTraces } from '@your-org/otel-decorators/testing'
*
* describe('UserService', () => {
* it('should create trace for user creation', async () => {
* const collector = collectTraces()
*
* const service = new UserService()
* await service.createUser({ email: 'test@example.com' })
*
* assertTraceCreated(collector, 'user.createUser')
* })
* })
* ```
*/
/**
* Create an in-memory trace collector for testing
*
* IMPORTANT: This automatically configures the global tracer to record spans.
* Call this in your test's beforeEach() to ensure proper setup.
*
* @example
* ```typescript
* import { createTraceCollector } from 'autotel/testing'
*
* describe('MyService', () => {
* let collector: TraceCollector
*
* beforeEach(() => {
* collector = createTraceCollector()
* })
*
* it('should trace operations', async () => {
* await myService.doSomething()
*
* const spans = collector.getSpansByName('myService.doSomething')
* expect(spans).toHaveLength(1)
* })
* })
* ```
*/
function createTraceCollector() {
const spans = [];
const createMockSpan = (name, startTime) => {
const spanData = {
name,
startTime,
attributes: {},
status: { code: SpanStatusCode.OK }
};
const spanContextData = {
traceId: "1234567890abcdef1234567890abcdef",
spanId: "1234567890abcdef",
traceFlags: 1,
isRemote: false
};
return {
spanContext: () => spanContextData,
setStatus(status) {
spanData.status = status;
return this;
},
setAttributes(attributes) {
spanData.attributes = {
...spanData.attributes,
...attributes
};
return this;
},
setAttribute(key, value) {
spanData.attributes = spanData.attributes || {};
spanData.attributes[key] = value;
return this;
},
addEvent(name, attributesOrStartTime, startTime) {
return this;
},
addLink(link) {
return this;
},
addLinks(links) {
return this;
},
updateName(newName) {
spanData.name = newName;
return this;
},
isRecording() {
return true;
},
recordException(exception, time) {},
end(endTimeArg) {
const endTime = performance.now();
spans.push({
name: spanData.name,
status: spanData.status,
attributes: spanData.attributes || {},
startTime: spanData.startTime,
endTime,
duration: endTime - spanData.startTime
});
}
};
};
configure({ tracer: {
startSpan(name, options, ctx) {
return createMockSpan(name, performance.now());
},
startActiveSpan(name, optionsOrFn, contextOrFn, fn) {
const callback = (() => {
if (typeof optionsOrFn === "function") return optionsOrFn;
if (typeof contextOrFn === "function") return contextOrFn;
if (fn) return fn;
throw new Error("startActiveSpan requires a callback");
})();
const mockSpan = createMockSpan(name, performance.now());
const ctx = trace.setSpan(context.active(), mockSpan);
return context.with(ctx, () => callback(mockSpan));
}
} });
return {
getSpans() {
return [...spans];
},
getSpansByName(name) {
return spans.filter((span) => span.name === name);
},
getSpansByAttributes(attributes) {
return spans.filter((span) => {
return Object.entries(attributes).every(([key, value]) => span.attributes[key] === value);
});
},
clear() {
spans.length = 0;
},
recordSpan(span) {
spans.push(span);
}
};
}
/**
* Assert that a trace was created for an operation
*
* @param collector - Trace collector
* @param operationName - Expected operation name
* @param options - Optional assertion options
* @throws Error if trace was not found or doesn't match expectations
*
* @example
* ```typescript
* assertTraceCreated(collector, 'user.createUser')
* assertTraceCreated(collector, 'user.createUser', {
* minCount: 1,
* maxCount: 1,
* status: SpanStatusCode.OK,
* attributes: { 'user.email': 'test@example.com' }
* })
* ```
*/
function assertTraceCreated(collector, operationName, options) {
const spans = collector.getSpansByName(operationName);
if (options?.minCount !== void 0 && spans.length < options.minCount) throw new Error(`Expected at least ${options.minCount} traces for ${operationName}, got ${spans.length}`);
if (options?.maxCount !== void 0 && spans.length > options.maxCount) throw new Error(`Expected at most ${options.maxCount} traces for ${operationName}, got ${spans.length}`);
if (spans.length === 0) throw new Error(`No traces found for operation: ${operationName}`);
if (options?.status !== void 0) {
if (spans.filter((span) => span.status.code === options.status).length === 0) throw new Error(`No traces with status ${options.status} found for ${operationName}`);
}
if (options?.attributes) {
if (spans.filter((span) => {
return Object.entries(options.attributes).every(([key, value]) => span.attributes[key] === value);
}).length === 0) throw new Error(`No traces with attributes ${JSON.stringify(options.attributes)} found for ${operationName}`);
}
}
/**
* Assert that no errors were logged
*
* Use this in smoke tests to verify critical paths don't have errors.
*
* @param collector - Trace collector
* @throws Error if any error traces are found
*
* @example
* ```typescript
* // Run critical user flows
* await runSmokeTests()
*
* // Verify no errors occurred
* assertNoErrors(collector)
* ```
*/
function assertNoErrors(collector) {
const errorSpans = collector.getSpans().filter((span) => span.status.code === SpanStatusCode.ERROR);
if (errorSpans.length > 0) {
const errorSummary = errorSpans.map((span) => `${span.name}: ${span.status.message}`).join("\n");
throw new Error(`Found ${errorSpans.length} error spans:\n${errorSummary}`);
}
}
/**
* Assert that a trace was created and succeeded
*
* @param collector - Trace collector
* @param operationName - Expected operation name
*
* @example
* ```typescript
* assertTraceSucceeded(collector, 'user.createUser')
* ```
*/
function assertTraceSucceeded(collector, operationName) {
assertTraceCreated(collector, operationName, { status: SpanStatusCode.OK });
}
/**
* Assert that a trace was created and failed
*
* @param collector - Trace collector
* @param operationName - Expected operation name
* @param errorMessage - Optional expected error message
*
* @example
* ```typescript
* assertTraceFailed(collector, 'user.createUser', 'Invalid email')
* ```
*/
function assertTraceFailed(collector, operationName, errorMessage) {
const spans = collector.getSpansByName(operationName);
if (spans.length === 0) throw new Error(`No traces found for operation: ${operationName}`);
const errorSpans = spans.filter((span) => span.status.code === SpanStatusCode.ERROR);
if (errorSpans.length === 0) throw new Error(`No error traces found for operation: ${operationName}`);
if (errorMessage) {
if (errorSpans.filter((span) => span.status.message === errorMessage).length === 0) throw new Error(`No error traces with message "${errorMessage}" found for ${operationName}`);
}
}
/**
* Create an in-memory log collector for testing
*
* @example
* ```typescript
* const logger = createMockLogger()
*
* // Use logger in your code
* service.log = logger
* await service.doSomething()
*
* // Assert logs were created
* const logs = logger.getLogs()
* expect(logs).toHaveLength(2)
* expect(logs[0].message).toBe('Operation started')
* ```
*/
function createMockLogger() {
const logs = [];
const createLogMethod = (level) => {
return (objOrMsg, msg) => {
if (typeof objOrMsg === "string") logs.push({
level,
message: objOrMsg,
extra: void 0
});
else logs.push({
level,
message: msg || "",
extra: objOrMsg
});
};
};
return {
info: createLogMethod("info"),
warn: createLogMethod("warn"),
debug: createLogMethod("debug"),
error(objOrMsg, msg) {
if (typeof objOrMsg === "string") {
logs.push({
level: "error",
message: objOrMsg,
extra: void 0,
error: void 0
});
return;
}
const { err, ...rest } = objOrMsg;
logs.push({
level: "error",
message: msg || "",
error: err instanceof Error ? err : void 0,
extra: err !== void 0 && !(err instanceof Error) ? {
err,
...rest
} : rest
});
},
getLogs() {
return [...logs];
},
getLogsByLevel(level) {
return logs.filter((log) => log.level === level);
},
getLogsByMessage(message) {
return logs.filter((log) => log.message.includes(message));
},
clear() {
logs.length = 0;
}
};
}
/**
* Assert that no error logs were created
*
* @param logger - Log collector
* @throws Error if any error logs are found
*
* @example
* ```typescript
* assertNoErrorsLogged(logger)
* ```
*/
function assertNoErrorsLogged(logger) {
const errorLogs = logger.getLogsByLevel("error");
if (errorLogs.length > 0) {
const errorSummary = errorLogs.map((log) => `${log.message}${log.error ? ": " + log.error.message : ""}`).join("\n");
throw new Error(`Found ${errorLogs.length} error logs:\n${errorSummary}`);
}
}
/**
* Wait for a specific trace to be created
*
* Useful for async operations where you need to wait for telemetry.
*
* @param collector - Trace collector
* @param operationName - Expected operation name
* @param timeoutMs - Timeout in milliseconds (default 5000)
* @returns Promise that resolves when trace is found
* @throws Error if timeout is reached
*
* @example
* ```typescript
* // Start async operation
* const promise = service.doAsyncWork()
*
* // Wait for trace
* await waitForTrace(collector, 'service.doAsyncWork', 1000)
*
* // Now you can assert on the trace
* assertTraceSucceeded(collector, 'service.doAsyncWork')
* ```
*/
async function waitForTrace(collector, operationName, timeoutMs = 5e3) {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (collector.getSpansByName(operationName).length > 0) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Timeout waiting for trace ${operationName} after ${timeoutMs}ms`);
}
/**
* Get trace duration in milliseconds
*
* @param collector - Trace collector
* @param operationName - Operation name
* @returns Duration in milliseconds, or undefined if trace not found
*
* @example
* ```typescript
* const duration = getTraceDuration(collector, 'user.createUser')
* expect(duration).toBeLessThan(1000) // Should be < 1s
* ```
*/
function getTraceDuration(collector, operationName) {
const spans = collector.getSpansByName(operationName);
if (spans.length === 0) return;
return spans[0]?.duration;
}
/**
* Assert that an operation completed within a time threshold
*
* Perfect for performance testing and SLO validation.
*
* @param collector - Trace collector
* @param operationName - Operation name
* @param maxDurationMs - Maximum allowed duration in milliseconds
* @throws Error if operation took too long
*
* @example
* ```typescript
* // Verify operation meets SLO
* await service.createUser({ email: 'test@example.com' })
* assertTraceDuration(collector, 'user.createUser', 500) // Must be < 500ms
* ```
*/
function assertTraceDuration(collector, operationName, maxDurationMs) {
const duration = getTraceDuration(collector, operationName);
if (duration === void 0) throw new Error(`No trace found for operation: ${operationName}`);
if (duration > maxDurationMs) throw new Error(`Operation ${operationName} took ${duration.toFixed(2)}ms, exceeding ${maxDurationMs}ms threshold`);
}
//#endregion
export { assertEventTracked, assertNoErrors, assertNoErrorsLogged, assertOutcomeTracked, assertTraceCreated, assertTraceDuration, assertTraceFailed, assertTraceSucceeded, createEventCollector, createMockLogger, createTraceCollector, getTraceDuration, waitForTrace };
//# sourceMappingURL=testing.js.map