Initial Commit
This commit is contained in:
99
packages/observability/monitor/opentelemetry/README.md
Normal file
99
packages/observability/monitor/opentelemetry/README.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# @suaveplan/opentelemetry - OpenTelemetry SDK setup and configuration helpers: tracer/meter/logger providers, resource detection, exporter configuration, and sampling strategies
|
||||
|
||||
## Summary
|
||||
|
||||
OpenTelemetry SDK setup and configuration helpers for Suaveplan. Provides batteries-included OTel initialization helpers, resource detection, ergonomic span/metrics wrappers, W3C trace-context propagation utilities, and a no-op SDK façade that works without any heavyweight `@opentelemetry/sdk-*` packages.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
|
||||
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
|
||||
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
|
||||
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
|
||||
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
|
||||
<!-- end:auto-docs --> - [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
|
||||
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
|
||||
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
|
||||
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
|
||||
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
|
||||
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
|
||||
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
|
||||
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
|
||||
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
|
||||
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
|
||||
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
|
||||
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
|
||||
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
|
||||
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
|
||||
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
|
||||
- [propagation](./src/core/propagation/propagation.md) — <!-- begin:auto-docs --> _(no submodules)_- [noop-sdk](./src/core/sdk/noop-sdk.md) — Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter loo…
|
||||
- [sdk-factory](./src/core/sdk/sdk-factory.md) — Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a valid…
|
||||
- [errors](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [opentelemetry-error](./src/errors/opentelemetry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel-config.schema](./src/schemas/otel-config.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics-utils](./src/utils/metrics-utils.md) — Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and e…
|
||||
- [resource](./src/utils/resource.md) — Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes ar…
|
||||
- [span-utils](./src/utils/span-utils.md) — Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finall…
|
||||
|
||||
## Packages
|
||||
|
||||
| Module | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `errors` | [src/errors/opentelemetry-error.md](src/errors/opentelemetry-error.md) | Error class and code registry |
|
||||
| `sdk` | [src/core/sdk/noop-sdk.md](src/core/sdk/noop-sdk.md) | `OtelSdk` interface and `NoopSdk` implementation |
|
||||
| `sdk-factory` | [src/core/sdk/sdk-factory.md](src/core/sdk/sdk-factory.md) | `createSdk` — validated config → SDK instance |
|
||||
| `span-utils` | [src/utils/span-utils.md](src/utils/span-utils.md) | `withSpan`, `recordException`, `addAttributes`, `getTraceContext` |
|
||||
| `metrics-utils` | [src/utils/metrics-utils.md](src/utils/metrics-utils.md) | `createCounter`, `createHistogram`, `createGauge`, `recordDuration` |
|
||||
| `resource` | [src/utils/resource.md](src/utils/resource.md) | `createResource` — OTel resource attribute builder |
|
||||
| `propagation` | [src/core/propagation/propagation.md](src/core/propagation/propagation.md) | W3C traceparent inject/extract/parse/build |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
import { withSpan } from "@suaveplan/opentelemetry/span-utils";
|
||||
import { createCounter, recordDuration } from "@suaveplan/opentelemetry/metrics-utils";
|
||||
|
||||
const sdk = createSdk({
|
||||
serviceName: "my-service",
|
||||
serviceVersion: "1.0.0",
|
||||
environment: "production",
|
||||
});
|
||||
|
||||
await sdk.start();
|
||||
|
||||
const tracer = sdk.getTracer("my-service");
|
||||
const meter = sdk.getMeter("my-service");
|
||||
|
||||
const requestCounter = createCounter(meter, "http.request.total", "Total HTTP requests");
|
||||
const latency = createHistogram(meter, "http.request.duration", "Request latency", "ms");
|
||||
|
||||
async function handleRequest(req: Request) {
|
||||
return withSpan(tracer, "http.handle", async (span) => {
|
||||
span.setAttribute("http.method", req.method);
|
||||
const start = Date.now();
|
||||
const response = await processRequest(req);
|
||||
requestCounter.add(1, { "http.status": response.status });
|
||||
recordDuration(latency, start, { "http.route": "/api" });
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => sdk.shutdown());
|
||||
```
|
||||
|
||||
## Subpath Exports
|
||||
|
||||
```ts
|
||||
import { ... } from "@suaveplan/opentelemetry"; // all exports
|
||||
import { createSdk, NoopSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
import { withSpan, recordException } from "@suaveplan/opentelemetry/span-utils";
|
||||
import { createCounter, createHistogram } from "@suaveplan/opentelemetry/metrics-utils";
|
||||
import { injectTraceContext, parseTraceParent } from "@suaveplan/opentelemetry/propagation";
|
||||
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
|
||||
```
|
||||
112
packages/observability/monitor/opentelemetry/package.json
Normal file
112
packages/observability/monitor/opentelemetry/package.json
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "@suaveplan/opentelemetry",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "OpenTelemetry SDK setup and configuration helpers: tracer/meter/logger providers, resource detection, exporter configuration, and sampling strategies",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./sdk": {
|
||||
"import": {
|
||||
"types": "./dist/core/sdk/sdk-factory.d.ts",
|
||||
"default": "./dist/core/sdk/sdk-factory.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/sdk/sdk-factory.d.ts",
|
||||
"default": "./dist/core/sdk/sdk-factory.js"
|
||||
}
|
||||
},
|
||||
"./span-utils": {
|
||||
"import": {
|
||||
"types": "./dist/utils/span-utils.d.ts",
|
||||
"default": "./dist/utils/span-utils.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/span-utils.d.ts",
|
||||
"default": "./dist/utils/span-utils.js"
|
||||
}
|
||||
},
|
||||
"./metrics-utils": {
|
||||
"import": {
|
||||
"types": "./dist/utils/metrics-utils.d.ts",
|
||||
"default": "./dist/utils/metrics-utils.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/metrics-utils.d.ts",
|
||||
"default": "./dist/utils/metrics-utils.js"
|
||||
}
|
||||
},
|
||||
"./propagation": {
|
||||
"import": {
|
||||
"types": "./dist/core/propagation/propagation.d.ts",
|
||||
"default": "./dist/core/propagation/propagation.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/propagation/propagation.d.ts",
|
||||
"default": "./dist/core/propagation/propagation.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/opentelemetry-error.d.ts",
|
||||
"default": "./dist/errors/opentelemetry-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/opentelemetry-error.d.ts",
|
||||
"default": "./dist/errors/opentelemetry-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
buildTraceParent,
|
||||
extractTraceContext,
|
||||
getActiveSpanIds,
|
||||
headerGetter,
|
||||
headerSetter,
|
||||
injectTraceContext,
|
||||
parseTraceParent,
|
||||
} from "./propagation";
|
||||
export { createSdk, NoopSdk, type OtelSdk } from "./sdk";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
buildTraceParent,
|
||||
extractTraceContext,
|
||||
getActiveSpanIds,
|
||||
headerGetter,
|
||||
headerSetter,
|
||||
injectTraceContext,
|
||||
parseTraceParent,
|
||||
} from "./propagation";
|
||||
@@ -0,0 +1,108 @@
|
||||
# propagation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides helpers for W3C Trace Context header injection and extraction, enabling distributed tracing across service boundaries. When a service makes an outgoing HTTP call it injects the current trace context into the request headers; when a service receives an inbound request it extracts the upstream trace context from the headers so subsequent spans are automatically parented to the upstream caller's span.
|
||||
|
||||
These utilities wrap `@opentelemetry/api`'s global propagator, meaning they respect whatever propagator implementation (or no-op) the application has registered — library code using these helpers does not need to know which propagation format is in use.
|
||||
|
||||
## Features
|
||||
|
||||
- `injectTraceContext` — adds `traceparent`/`tracestate` headers to an outgoing headers map
|
||||
- `extractTraceContext` — parses incoming headers and activates the remote span context
|
||||
- `buildTraceParent` — formats a W3C `traceparent` header string from parts
|
||||
- `parseTraceParent` — validates and parses a `traceparent` header into structured fields
|
||||
- `getActiveSpanIds` — returns the active span's trace ID and span ID for manual use
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { injectTraceContext, extractTraceContext } from "@suaveplan/opentelemetry/propagation";
|
||||
|
||||
// Outgoing HTTP call
|
||||
const headers = injectTraceContext({ "content-type": "application/json" });
|
||||
await fetch("https://downstream.service/api", { headers });
|
||||
|
||||
// Incoming HTTP request handler
|
||||
extractTraceContext(req.headers as Record<string, string>);
|
||||
// All spans created after this call will be children of the upstream span
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildTraceParent,
|
||||
parseTraceParent,
|
||||
injectTraceContext,
|
||||
} from "@suaveplan/opentelemetry/propagation";
|
||||
|
||||
// Build a traceparent manually for synthetic test traces
|
||||
const traceparent = buildTraceParent(
|
||||
"4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
"00f067aa0ba902b7",
|
||||
1 // sampled
|
||||
);
|
||||
// "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
||||
|
||||
// Parse and validate a received header
|
||||
const parsed = parseTraceParent(traceparent);
|
||||
if (parsed !== null) {
|
||||
console.log(`trace=${parsed.traceId} span=${parsed.spanId} sampled=${parsed.flags & 1}`);
|
||||
}
|
||||
|
||||
// null for invalid headers
|
||||
parseTraceParent("not-a-header"); // null
|
||||
parseTraceParent("01-invalid-version-format"); // null
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `injectTraceContext(headers)`
|
||||
|
||||
```ts
|
||||
function injectTraceContext(headers: Record<string, string>): Record<string, string>
|
||||
```
|
||||
|
||||
Injects the active W3C trace context into `headers` via the global propagator. Returns the same object with headers added. Safe to call with no active span — the propagator no-ops gracefully.
|
||||
|
||||
### `extractTraceContext(headers)`
|
||||
|
||||
```ts
|
||||
function extractTraceContext(headers: Record<string, string>): void
|
||||
```
|
||||
|
||||
Extracts a remote span context from `headers` using the global propagator. No-op when no recognisable headers are present.
|
||||
|
||||
### `buildTraceParent(traceId, spanId, flags?)`
|
||||
|
||||
```ts
|
||||
function buildTraceParent(traceId: string, spanId: string, flags?: number): string
|
||||
```
|
||||
|
||||
Formats a W3C `traceparent` header: `"00-{traceId}-{spanId}-{flags:02x}"`. `flags` defaults to `1` (sampled bit set).
|
||||
|
||||
### `parseTraceParent(header)`
|
||||
|
||||
```ts
|
||||
function parseTraceParent(header: string): { traceId: string; spanId: string; flags: number } | null
|
||||
```
|
||||
|
||||
Parses and validates a `traceparent` header. Returns `null` for any invalid format, including wrong version byte, wrong lengths, or non-hex characters.
|
||||
|
||||
### `getActiveSpanIds()`
|
||||
|
||||
```ts
|
||||
function getActiveSpanIds(): { traceId?: string; spanId?: string }
|
||||
```
|
||||
|
||||
Returns the currently active span's `traceId` and `spanId`. Returns an empty object when no span is active.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`injectTraceContext` and `extractTraceContext` use custom `TextMapSetter` / `TextMapGetter` implementations that operate directly on `Record<string, string>` objects rather than requiring a specific carrier type. This avoids a dependency on `@opentelemetry/core`'s `defaultTextMapGetter`/`defaultTextMapSetter`.
|
||||
|
||||
`parseTraceParent` uses a strict regex (`/^00-.../`) that requires version `00` — the only currently defined version in the W3C spec. Future spec versions would need explicit handling. The `TRACE_ID_RE` and `SPAN_ID_RE` guards after the regex match are redundant for inputs that passed the outer regex but are kept for defence-in-depth in case the outer pattern is ever loosened.
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildTraceParent,
|
||||
extractTraceContext,
|
||||
getActiveSpanIds,
|
||||
headerGetter,
|
||||
headerSetter,
|
||||
injectTraceContext,
|
||||
parseTraceParent,
|
||||
} from "./propagation.js";
|
||||
|
||||
const VALID_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736";
|
||||
const VALID_SPAN_ID = "00f067aa0ba902b7";
|
||||
|
||||
describe("propagation", () => {
|
||||
describe("buildTraceParent", () => {
|
||||
it("produces correct W3C traceparent format", { timeout: 5000 }, () => {
|
||||
const result = buildTraceParent(VALID_TRACE_ID, VALID_SPAN_ID, 1);
|
||||
expect(result).toBe(`00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-01`);
|
||||
});
|
||||
|
||||
it("defaults flags to 1 (sampled)", { timeout: 5000 }, () => {
|
||||
const result = buildTraceParent(VALID_TRACE_ID, VALID_SPAN_ID);
|
||||
expect(result).toMatch(/-01$/);
|
||||
});
|
||||
|
||||
it("encodes flags as 2-digit hex", { timeout: 5000 }, () => {
|
||||
const result = buildTraceParent(VALID_TRACE_ID, VALID_SPAN_ID, 0);
|
||||
expect(result).toMatch(/-00$/);
|
||||
});
|
||||
|
||||
it("handles non-zero flags other than 1", { timeout: 5000 }, () => {
|
||||
const result = buildTraceParent(VALID_TRACE_ID, VALID_SPAN_ID, 255);
|
||||
expect(result).toMatch(/-ff$/);
|
||||
});
|
||||
|
||||
it("starts with version 00", { timeout: 5000 }, () => {
|
||||
const result = buildTraceParent(VALID_TRACE_ID, VALID_SPAN_ID);
|
||||
expect(result).toMatch(/^00-/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTraceParent", () => {
|
||||
it("parses a valid traceparent header", { timeout: 5000 }, () => {
|
||||
const header = `00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-01`;
|
||||
const result = parseTraceParent(header);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.traceId).toBe(VALID_TRACE_ID);
|
||||
expect(result?.spanId).toBe(VALID_SPAN_ID);
|
||||
expect(result?.flags).toBe(1);
|
||||
});
|
||||
|
||||
it("parses flags=0 correctly", { timeout: 5000 }, () => {
|
||||
const header = `00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-00`;
|
||||
const result = parseTraceParent(header);
|
||||
expect(result?.flags).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for an empty string", { timeout: 5000 }, () => {
|
||||
expect(parseTraceParent("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a random string", { timeout: 5000 }, () => {
|
||||
expect(parseTraceParent("not-a-header")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for wrong version byte", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
parseTraceParent(`01-${VALID_TRACE_ID}-${VALID_SPAN_ID}-01`)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for traceId that is too short", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
parseTraceParent(`00-abc123-${VALID_SPAN_ID}-01`)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for spanId that is too short", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parseTraceParent(`00-${VALID_TRACE_ID}-abc-01`)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for missing flags segment", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
parseTraceParent(`00-${VALID_TRACE_ID}-${VALID_SPAN_ID}`)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips with buildTraceParent", { timeout: 5000 }, () => {
|
||||
const header = buildTraceParent(VALID_TRACE_ID, VALID_SPAN_ID, 1);
|
||||
const parsed = parseTraceParent(header);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.traceId).toBe(VALID_TRACE_ID);
|
||||
expect(parsed?.spanId).toBe(VALID_SPAN_ID);
|
||||
expect(parsed?.flags).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectTraceContext", () => {
|
||||
it("returns an object with string values (or the same map unchanged when no span is active)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
const result = injectTraceContext(headers);
|
||||
expect(result).toBe(headers); // same reference
|
||||
for (const [, v] of Object.entries(result)) {
|
||||
expect(typeof v).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves existing headers", { timeout: 5000 }, () => {
|
||||
const headers = {
|
||||
"x-custom": "value",
|
||||
authorization: "Bearer tok",
|
||||
};
|
||||
const result = injectTraceContext(headers);
|
||||
expect(result["x-custom"]).toBe("value");
|
||||
expect(result["authorization"]).toBe("Bearer tok");
|
||||
});
|
||||
|
||||
it("does not throw when no propagator is registered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => injectTraceContext({})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTraceContext", () => {
|
||||
it("does not throw for empty headers", { timeout: 5000 }, () => {
|
||||
expect(() => extractTraceContext({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw for headers with a valid traceparent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const headers = {
|
||||
traceparent: `00-${VALID_TRACE_ID}-${VALID_SPAN_ID}-01`,
|
||||
};
|
||||
expect(() => extractTraceContext(headers)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw for invalid traceparent values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
extractTraceContext({ traceparent: "garbage" })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveSpanIds", () => {
|
||||
it("returns an object (never throws)", { timeout: 5000 }, () => {
|
||||
expect(() => getActiveSpanIds()).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns empty object when no span is active (no-op tracer)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ids = getActiveSpanIds();
|
||||
// With no-op tracer there is no active span
|
||||
expect(ids).toEqual({});
|
||||
});
|
||||
|
||||
it("returns traceId and spanId when a span context is provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fakeCtx = {
|
||||
traceId: VALID_TRACE_ID,
|
||||
spanId: VALID_SPAN_ID,
|
||||
};
|
||||
const ids = getActiveSpanIds(fakeCtx);
|
||||
expect(ids.traceId).toBe(VALID_TRACE_ID);
|
||||
expect(ids.spanId).toBe(VALID_SPAN_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("headerSetter", () => {
|
||||
it("sets a key on the carrier", { timeout: 5000 }, () => {
|
||||
const carrier: Record<string, string> = {};
|
||||
headerSetter.set(carrier, "traceparent", "00-abc-def-01");
|
||||
expect(carrier["traceparent"]).toBe("00-abc-def-01");
|
||||
});
|
||||
|
||||
it("overwrites existing key", { timeout: 5000 }, () => {
|
||||
const carrier = { "x-key": "old" };
|
||||
headerSetter.set(carrier, "x-key", "new");
|
||||
expect(carrier["x-key"]).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("headerGetter", () => {
|
||||
it("gets an existing key from the carrier", { timeout: 5000 }, () => {
|
||||
const carrier = { traceparent: "00-abc-def-01" };
|
||||
expect(headerGetter.get(carrier, "traceparent")).toBe(
|
||||
"00-abc-def-01"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for a missing key", { timeout: 5000 }, () => {
|
||||
const carrier: Record<string, string> = {};
|
||||
expect(headerGetter.get(carrier, "missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keys() returns all carrier keys", { timeout: 5000 }, () => {
|
||||
const carrier = { a: "1", b: "2", c: "3" };
|
||||
expect(headerGetter.keys(carrier).sort()).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("keys() returns empty array for empty carrier", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(headerGetter.keys({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { context, propagation, trace } from "@opentelemetry/api";
|
||||
|
||||
/** Pattern for validating a W3C traceparent header. */
|
||||
const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
||||
|
||||
/**
|
||||
* TextMap setter for string-keyed header maps.
|
||||
* Exported for direct unit testing.
|
||||
* @internal
|
||||
*/
|
||||
export const headerSetter = {
|
||||
set(carrier: Record<string, string>, key: string, value: string): void {
|
||||
carrier[key] = value;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* TextMap getter for string-keyed header maps.
|
||||
* Exported for direct unit testing.
|
||||
* @internal
|
||||
*/
|
||||
export const headerGetter = {
|
||||
get(carrier: Record<string, string>, key: string): string | undefined {
|
||||
return carrier[key];
|
||||
},
|
||||
keys(carrier: Record<string, string>): string[] {
|
||||
return Object.keys(carrier);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Inject the active W3C trace context into a headers object.
|
||||
*
|
||||
* Delegates to `@opentelemetry/api`'s global propagator, which will set
|
||||
* `traceparent` (and optionally `tracestate`) based on the currently active
|
||||
* span. If no propagator is registered the headers map is returned unchanged.
|
||||
*
|
||||
* @param headers - Mutable string map to inject context into (e.g. HTTP request headers).
|
||||
* @returns The same map with OTel context headers added.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const outgoing = injectTraceContext({ "content-type": "application/json" });
|
||||
* // outgoing["traceparent"] === "00-<traceId>-<spanId>-01"
|
||||
* ```
|
||||
*/
|
||||
export function injectTraceContext(
|
||||
headers: Record<string, string>
|
||||
): Record<string, string> {
|
||||
propagation.inject(context.active(), headers, headerSetter);
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a remote span context from incoming headers and activate it.
|
||||
*
|
||||
* Parses the `traceparent` (and optionally `tracestate`) header using the
|
||||
* global propagator and sets the resulting context as active on the current
|
||||
* async context. This is a no-op when no recognisable propagation header is
|
||||
* present.
|
||||
*
|
||||
* @param headers - Incoming HTTP headers (or any string map) to extract from.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* extractTraceContext(req.headers as Record<string, string>);
|
||||
* // Subsequent trace.getActiveSpan() calls will see the extracted parent
|
||||
* ```
|
||||
*/
|
||||
export function extractTraceContext(headers: Record<string, string>): void {
|
||||
propagation.extract(context.active(), headers, headerGetter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a W3C `traceparent` header string.
|
||||
*
|
||||
* Produces the canonical `00-<traceId>-<spanId>-<flags>` format defined by
|
||||
* the W3C Trace Context specification (version `00`).
|
||||
*
|
||||
* @param traceId - 32 lower-hex-character trace ID.
|
||||
* @param spanId - 16 lower-hex-character span (parent) ID.
|
||||
* @param flags - Trace flags byte (defaults to `1` — sampled).
|
||||
* @returns Formatted `traceparent` header value.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* buildTraceParent(
|
||||
* "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
* "00f067aa0ba902b7",
|
||||
* 1
|
||||
* );
|
||||
* // "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
||||
* ```
|
||||
*/
|
||||
export function buildTraceParent(
|
||||
traceId: string,
|
||||
spanId: string,
|
||||
flags = 1
|
||||
): string {
|
||||
const flagsHex = flags.toString(16).padStart(2, "0");
|
||||
return `00-${traceId}-${spanId}-${flagsHex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a W3C `traceparent` header string.
|
||||
*
|
||||
* Returns `null` for any header that does not conform to the
|
||||
* `00-<traceId>-<spanId>-<flags>` format.
|
||||
*
|
||||
* @param header - Raw `traceparent` header value to parse.
|
||||
* @returns Parsed components or `null` if the header is invalid.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* parseTraceParent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
|
||||
* // { traceId: "4bf92f3577b34da6a3ce929d0e0e4736", spanId: "00f067aa0ba902b7", flags: 1 }
|
||||
*
|
||||
* parseTraceParent("invalid"); // null
|
||||
* ```
|
||||
*/
|
||||
export function parseTraceParent(
|
||||
header: string
|
||||
): { traceId: string; spanId: string; flags: number } | null {
|
||||
const match = TRACEPARENT_RE.exec(header);
|
||||
if (!match) return null;
|
||||
|
||||
const traceId = match[1] as string;
|
||||
const spanId = match[2] as string;
|
||||
const flagsHex = match[3] as string;
|
||||
|
||||
return {
|
||||
traceId,
|
||||
spanId,
|
||||
flags: Number.parseInt(flagsHex, 16),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active span's trace and span IDs for use in propagation.
|
||||
*
|
||||
* Returns the `traceId` and `spanId` from `spanContext` if provided, otherwise
|
||||
* reads from the currently active span. Both fields are optional and are only
|
||||
* present when a span context is available.
|
||||
*
|
||||
* @param spanContext - Optional span context to read from. Defaults to the active span.
|
||||
* @returns An object with `traceId` and `spanId`, or an empty object when no span is active.
|
||||
*/
|
||||
export function getActiveSpanIds(spanContext?: {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
}): { traceId?: string; spanId?: string } {
|
||||
const sc = spanContext ?? trace.getActiveSpan()?.spanContext();
|
||||
if (sc === undefined) return {};
|
||||
return { traceId: sc.traceId, spanId: sc.spanId };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { NoopSdk, type OtelSdk } from "./noop-sdk";
|
||||
export { createSdk } from "./sdk-factory";
|
||||
@@ -0,0 +1,96 @@
|
||||
# noop-sdk
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides `NoopSdk`, a zero-dependency implementation of the `OtelSdk` interface that delegates all tracer and meter lookups to `@opentelemetry/api`'s built-in no-op providers. It also exports the `OtelSdk` interface itself, which is the abstraction callers program against regardless of which concrete implementation they use.
|
||||
|
||||
Having a no-op default means library packages can instrument their code with spans and metrics without forcing a specific OpenTelemetry SDK on their consumers. Applications that want real telemetry simply register an SDK before `start()` is called; libraries that don't need it continue working unchanged with zero overhead.
|
||||
|
||||
## Features
|
||||
|
||||
- `OtelSdk` interface — the contract for all SDK implementations in this package
|
||||
- `NoopSdk` class — fully functional no-op implementation; start/shutdown are immediate no-ops
|
||||
- `getTracer` / `getMeter` — delegate to `@opentelemetry/api` global providers (no-op by default)
|
||||
- Lifecycle tracking via `isStarted()` — useful for guard checks and health probes
|
||||
- Zero external dependencies beyond `@opentelemetry/api`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NoopSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
|
||||
const sdk = new NoopSdk();
|
||||
await sdk.start();
|
||||
|
||||
const tracer = sdk.getTracer("my-service");
|
||||
// Spans created with this tracer are silently discarded
|
||||
|
||||
await sdk.shutdown();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Injecting `NoopSdk` in tests while using a real SDK in production:
|
||||
|
||||
```ts
|
||||
import type { OtelSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
import { NoopSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
|
||||
class PaymentService {
|
||||
constructor(private readonly sdk: OtelSdk) {}
|
||||
|
||||
async charge(amount: number): Promise<void> {
|
||||
const tracer = this.sdk.getTracer("payment-service");
|
||||
const span = tracer.startSpan("payment.charge");
|
||||
try {
|
||||
await processCharge(amount);
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// In tests:
|
||||
const svc = new PaymentService(new NoopSdk());
|
||||
|
||||
// In production (with a real SDK implementation):
|
||||
// const realSdk = createSdk({ serviceName: "payment-service", ... });
|
||||
// const svc = new PaymentService(realSdk);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `OtelSdk` interface
|
||||
|
||||
```ts
|
||||
interface OtelSdk {
|
||||
start(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
isStarted(): boolean;
|
||||
getTracer(name: string, version?: string): Tracer;
|
||||
getMeter(name: string, version?: string): Meter;
|
||||
}
|
||||
```
|
||||
|
||||
The contract all SDK implementations satisfy.
|
||||
|
||||
### `NoopSdk`
|
||||
|
||||
```ts
|
||||
class NoopSdk implements OtelSdk {
|
||||
start(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
isStarted(): boolean;
|
||||
getTracer(name: string, version?: string): Tracer;
|
||||
getMeter(name: string, version?: string): Meter;
|
||||
}
|
||||
```
|
||||
|
||||
- `start()` — sets `isStarted()` to `true` and resolves immediately
|
||||
- `shutdown()` — sets `isStarted()` to `false` and resolves immediately
|
||||
- `getTracer(name, version?)` — returns `trace.getTracer(name, version)` from the `@opentelemetry/api` global
|
||||
- `getMeter(name, version?)` — returns `metrics.getMeter(name, version)` from the `@opentelemetry/api` global
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `started` field is `protected` rather than `private` to allow subclasses (e.g. a real SDK wrapper that extends `NoopSdk`) to read and modify it. This follows the project's preference for `protected` over `private`. `getTracer` and `getMeter` are not guarded by `isStarted()` by design — the no-op API already handles calls made before registration silently, and guarding would add overhead in the hot path without benefit.
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { NoopSdk } from "./noop-sdk.js";
|
||||
|
||||
describe("NoopSdk", () => {
|
||||
describe("lifecycle", () => {
|
||||
it("start() resolves without throwing", { timeout: 5000 }, async () => {
|
||||
const sdk = new NoopSdk();
|
||||
await expect(sdk.start()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("shutdown() resolves without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sdk = new NoopSdk();
|
||||
await sdk.start();
|
||||
await expect(sdk.shutdown()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("isStarted() returns false before start()", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sdk = new NoopSdk();
|
||||
expect(sdk.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("isStarted() returns true after start()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sdk = new NoopSdk();
|
||||
await sdk.start();
|
||||
expect(sdk.isStarted()).toBe(true);
|
||||
});
|
||||
|
||||
it("isStarted() returns false after shutdown()", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sdk = new NoopSdk();
|
||||
await sdk.start();
|
||||
await sdk.shutdown();
|
||||
expect(sdk.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("start() and shutdown() can be called multiple times without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sdk = new NoopSdk();
|
||||
await sdk.start();
|
||||
await sdk.start();
|
||||
await sdk.shutdown();
|
||||
await sdk.shutdown();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTracer", () => {
|
||||
it("returns a non-null Tracer", { timeout: 5000 }, () => {
|
||||
const sdk = new NoopSdk();
|
||||
const tracer = sdk.getTracer("test-service");
|
||||
expect(tracer).not.toBeNull();
|
||||
expect(tracer).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns a Tracer with a startSpan method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sdk = new NoopSdk();
|
||||
const tracer = sdk.getTracer("test-service");
|
||||
expect(typeof tracer.startSpan).toBe("function");
|
||||
});
|
||||
|
||||
it("accepts an optional version", { timeout: 5000 }, () => {
|
||||
const sdk = new NoopSdk();
|
||||
const tracer = sdk.getTracer("test-service", "1.0.0");
|
||||
expect(tracer).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not throw when SDK is not started", { timeout: 5000 }, () => {
|
||||
const sdk = new NoopSdk();
|
||||
expect(() => sdk.getTracer("test-service")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMeter", () => {
|
||||
it("returns a non-null Meter", { timeout: 5000 }, () => {
|
||||
const sdk = new NoopSdk();
|
||||
const meter = sdk.getMeter("test-service");
|
||||
expect(meter).not.toBeNull();
|
||||
expect(meter).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns a Meter with a createCounter method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const sdk = new NoopSdk();
|
||||
const meter = sdk.getMeter("test-service");
|
||||
expect(typeof meter.createCounter).toBe("function");
|
||||
});
|
||||
|
||||
it("accepts an optional version", { timeout: 5000 }, () => {
|
||||
const sdk = new NoopSdk();
|
||||
const meter = sdk.getMeter("test-service", "2.0.0");
|
||||
expect(meter).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not throw when SDK is not started", { timeout: 5000 }, () => {
|
||||
const sdk = new NoopSdk();
|
||||
expect(() => sdk.getMeter("test-service")).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type Meter, metrics, type Tracer, trace } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Contract for the OTel SDK facade exposed by this package.
|
||||
*
|
||||
* Callers always interact with `OtelSdk` and can swap implementations
|
||||
* (no-op, real SDK, test double) without changing call sites.
|
||||
*/
|
||||
export interface OtelSdk {
|
||||
/** Start the SDK and initialise all configured providers. */
|
||||
start(): Promise<void>;
|
||||
/** Flush pending telemetry and shut down all providers. */
|
||||
shutdown(): Promise<void>;
|
||||
/** Returns `true` once `start()` has resolved successfully. */
|
||||
isStarted(): boolean;
|
||||
/** Returns a `Tracer` from the active global tracer provider. */
|
||||
getTracer(name: string, version?: string): Tracer;
|
||||
/** Returns a `Meter` from the active global meter provider. */
|
||||
getMeter(name: string, version?: string): Meter;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op implementation of `OtelSdk` backed entirely by the `@opentelemetry/api`
|
||||
* no-op providers.
|
||||
*
|
||||
* This implementation is useful in three scenarios:
|
||||
* 1. **Tests** — no real exporter or SDK package is needed.
|
||||
* 2. **Library packages** — ship tracing instrumentation without pulling in SDK
|
||||
* packages; the application layer registers a real provider if desired.
|
||||
* 3. **Feature flags** — disable telemetry at runtime by injecting a `NoopSdk`.
|
||||
*
|
||||
* `start()` and `shutdown()` resolve immediately. `getTracer` and `getMeter`
|
||||
* delegate to `@opentelemetry/api`'s global no-op implementations, so spans
|
||||
* and metrics are silently discarded.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { NoopSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
*
|
||||
* const sdk = new NoopSdk();
|
||||
* await sdk.start();
|
||||
* const tracer = sdk.getTracer("my-service");
|
||||
* // tracer is @opentelemetry/api's no-op tracer — spans are discarded
|
||||
* await sdk.shutdown();
|
||||
* ```
|
||||
*/
|
||||
export class NoopSdk implements OtelSdk {
|
||||
protected started = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
getTracer(name: string, version?: string): Tracer {
|
||||
return trace.getTracer(name, version);
|
||||
}
|
||||
|
||||
getMeter(name: string, version?: string): Meter {
|
||||
return metrics.getMeter(name, version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# sdk-factory
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the `createSdk` factory function — the primary entry point for constructing an `OtelSdk` instance from a validated `OtelConfig`. It performs Zod schema validation before any SDK construction, so callers get a clear, structured error if the configuration is malformed rather than a cryptic runtime failure deep inside an SDK constructor.
|
||||
|
||||
The factory intentionally returns a `NoopSdk` today. This is not a limitation — it is a deliberate separation of concerns: this package is responsible for OTel configuration helpers and ergonomic wrappers, not for pulling in heavyweight `@opentelemetry/sdk-*` packages. Applications that need real telemetry install the SDK packages themselves and either extend `NoopSdk` or register providers directly.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates the full `OtelConfig` structure with Zod before constructing the SDK
|
||||
- Throws a typed `OpenTelemetryError` with `OTEL.INVALID_CONFIG` and structured issue context on validation failure
|
||||
- Development-mode config logging via `console.debug` when `NODE_ENV === "development"`
|
||||
- Returns an `OtelSdk`-typed value — callers are decoupled from the concrete implementation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
|
||||
const sdk = createSdk({ serviceName: "my-service" });
|
||||
await sdk.start();
|
||||
const tracer = sdk.getTracer("my-service");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
|
||||
|
||||
// Full config with traces, metrics, and logs
|
||||
const sdk = createSdk({
|
||||
serviceName: "payment-service",
|
||||
serviceVersion: "2.0.0",
|
||||
environment: "staging",
|
||||
traces: {
|
||||
exporter: "otlp-http",
|
||||
exporterUrl: "https://otel.internal.example.com/v1/traces",
|
||||
sampler: "trace-id-ratio",
|
||||
sampleRate: 0.1,
|
||||
propagators: ["w3c-tracecontext", "baggage"],
|
||||
batchSize: 512,
|
||||
flushInterval: 5000,
|
||||
},
|
||||
metrics: {
|
||||
exporter: "otlp-http",
|
||||
exporterUrl: "https://otel.internal.example.com/v1/metrics",
|
||||
exportIntervalMs: 30000,
|
||||
},
|
||||
logs: {
|
||||
exporter: "otlp-http",
|
||||
exporterUrl: "https://otel.internal.example.com/v1/logs",
|
||||
},
|
||||
});
|
||||
|
||||
// Invalid config — throws OpenTelemetryError
|
||||
try {
|
||||
createSdk({ serviceName: "" }); // empty string fails min(1)
|
||||
} catch (err) {
|
||||
if (err instanceof OpenTelemetryError && err.code === OTEL_CODES.INVALID_CONFIG) {
|
||||
console.error("Config invalid:", err.context?.issues);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createSdk(config)`
|
||||
|
||||
```ts
|
||||
function createSdk(config: OtelConfig): OtelSdk
|
||||
```
|
||||
|
||||
Validates `config` with `OtelConfigSchema` and returns an `OtelSdk` instance.
|
||||
|
||||
- `config` — must satisfy the `OtelConfig` shape; see `otel-config.schema.ts` for full validation rules
|
||||
- **Returns** an `OtelSdk` (currently `NoopSdk`)
|
||||
- **Throws** `OpenTelemetryError` with code `OTEL.INVALID_CONFIG` when Zod validation fails; `err.context.issues` contains an array of `{ path, message }` objects
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory uses `OtelConfigSchema.safeParse` (not `.parse`) so validation failures are caught locally and re-thrown as `OpenTelemetryError` instances rather than Zod errors. This ensures callers always get typed errors from this package regardless of which validation library is in use.
|
||||
|
||||
Development logging is gated behind both `typeof process !== "undefined"` (safe in edge runtimes) and `process.env.NODE_ENV === "development"` (never logs in production or test). The logged object is `result.data` — the parsed and normalised output, not the raw input, so any Zod transformations are reflected.
|
||||
|
||||
The `NoopSdk` return is intentional and documented as a design contract rather than a TODO: applications that need real telemetry register their own providers and optionally wrap or replace the returned instance. This keeps `@suaveplan/opentelemetry` free of `@opentelemetry/sdk-*` dependencies.
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
OpenTelemetryError,
|
||||
OTEL_CODES,
|
||||
} from "../../errors/opentelemetry-error.js";
|
||||
import { createSdk } from "./sdk-factory.js";
|
||||
|
||||
describe("createSdk", () => {
|
||||
describe("valid config", () => {
|
||||
it("returns an OtelSdk for a minimal config", { timeout: 5000 }, () => {
|
||||
const sdk = createSdk({ serviceName: "my-service" });
|
||||
expect(sdk).toBeDefined();
|
||||
expect(typeof sdk.start).toBe("function");
|
||||
expect(typeof sdk.shutdown).toBe("function");
|
||||
expect(typeof sdk.isStarted).toBe("function");
|
||||
expect(typeof sdk.getTracer).toBe("function");
|
||||
expect(typeof sdk.getMeter).toBe("function");
|
||||
});
|
||||
|
||||
it("returns an OtelSdk for a full config", { timeout: 5000 }, () => {
|
||||
const sdk = createSdk({
|
||||
serviceName: "payment-service",
|
||||
serviceVersion: "1.0.0",
|
||||
environment: "staging",
|
||||
traces: {
|
||||
exporter: "otlp-http",
|
||||
exporterUrl: "https://otel.example.com/v1/traces",
|
||||
sampler: "trace-id-ratio",
|
||||
sampleRate: 0.5,
|
||||
propagators: ["w3c-tracecontext", "baggage"],
|
||||
batchSize: 512,
|
||||
flushInterval: 5000,
|
||||
},
|
||||
metrics: {
|
||||
exporter: "otlp-http",
|
||||
exporterUrl: "https://otel.example.com/v1/metrics",
|
||||
exportIntervalMs: 30000,
|
||||
},
|
||||
logs: {
|
||||
exporter: "console",
|
||||
},
|
||||
});
|
||||
expect(sdk).toBeDefined();
|
||||
});
|
||||
|
||||
it("returned SDK starts and stops correctly", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const sdk = createSdk({ serviceName: "test-service" });
|
||||
expect(sdk.isStarted()).toBe(false);
|
||||
await sdk.start();
|
||||
expect(sdk.isStarted()).toBe(true);
|
||||
await sdk.shutdown();
|
||||
expect(sdk.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it("returned SDK provides a tracer", { timeout: 5000 }, () => {
|
||||
const sdk = createSdk({ serviceName: "tracer-service" });
|
||||
const tracer = sdk.getTracer("tracer-service");
|
||||
expect(tracer).toBeDefined();
|
||||
});
|
||||
|
||||
it("returned SDK provides a meter", { timeout: 5000 }, () => {
|
||||
const sdk = createSdk({ serviceName: "meter-service" });
|
||||
const meter = sdk.getMeter("meter-service");
|
||||
expect(meter).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts all valid exporter types", { timeout: 5000 }, () => {
|
||||
for (const exporter of [
|
||||
"console",
|
||||
"otlp-http",
|
||||
"otlp-grpc",
|
||||
"noop",
|
||||
] as const) {
|
||||
expect(() =>
|
||||
createSdk({ serviceName: "svc", traces: { exporter } })
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts all valid sampler types", { timeout: 5000 }, () => {
|
||||
for (const sampler of [
|
||||
"always-on",
|
||||
"always-off",
|
||||
"trace-id-ratio",
|
||||
"parent-based",
|
||||
] as const) {
|
||||
expect(() =>
|
||||
createSdk({ serviceName: "svc", traces: { sampler } })
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid config", () => {
|
||||
it("throws OpenTelemetryError for empty serviceName", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => createSdk({ serviceName: "" })).toThrow(
|
||||
OpenTelemetryError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with OTEL.INVALID_CONFIG code", { timeout: 5000 }, () => {
|
||||
try {
|
||||
createSdk({ serviceName: "" });
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenTelemetryError);
|
||||
if (err instanceof OpenTelemetryError) {
|
||||
expect(err.code).toBe(OTEL_CODES.INVALID_CONFIG);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("throws for sampleRate out of range (above 1.0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createSdk({ serviceName: "svc", traces: { sampleRate: 1.5 } })
|
||||
).toThrow(OpenTelemetryError);
|
||||
});
|
||||
|
||||
it("throws for sampleRate out of range (below 0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
createSdk({ serviceName: "svc", traces: { sampleRate: -0.1 } })
|
||||
).toThrow(OpenTelemetryError);
|
||||
});
|
||||
|
||||
it("throws for invalid exporterUrl", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createSdk({
|
||||
serviceName: "svc",
|
||||
traces: { exporter: "otlp-http", exporterUrl: "not-a-url" },
|
||||
})
|
||||
).toThrow(OpenTelemetryError);
|
||||
});
|
||||
|
||||
it("throws for invalid exporter type", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createSdk({
|
||||
serviceName: "svc",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
traces: { exporter: "invalid-exporter" as any },
|
||||
})
|
||||
).toThrow(OpenTelemetryError);
|
||||
});
|
||||
|
||||
it("throws for negative exportIntervalMs", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
createSdk({
|
||||
serviceName: "svc",
|
||||
metrics: { exportIntervalMs: -1 },
|
||||
})
|
||||
).toThrow(OpenTelemetryError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("development mode logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("logs config to console.debug in development mode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const originalNodeEnv = process.env["NODE_ENV"];
|
||||
process.env["NODE_ENV"] = "development";
|
||||
try {
|
||||
createSdk({ serviceName: "dev-service" });
|
||||
expect(console.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[opentelemetry]"),
|
||||
expect.stringContaining("dev-service")
|
||||
);
|
||||
} finally {
|
||||
process.env["NODE_ENV"] = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not log in non-development mode", { timeout: 5000 }, () => {
|
||||
const originalNodeEnv = process.env["NODE_ENV"];
|
||||
process.env["NODE_ENV"] = "production";
|
||||
try {
|
||||
createSdk({ serviceName: "prod-service" });
|
||||
expect(console.debug).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
process.env["NODE_ENV"] = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
OpenTelemetryError,
|
||||
OTEL_CODES,
|
||||
} from "../../errors/opentelemetry-error.js";
|
||||
import {
|
||||
type OtelConfig,
|
||||
OtelConfigSchema,
|
||||
} from "../../schemas/otel-config.schema.js";
|
||||
import { NoopSdk, type OtelSdk } from "./noop-sdk.js";
|
||||
|
||||
/**
|
||||
* Create and return an `OtelSdk` instance for the given configuration.
|
||||
*
|
||||
* **Configuration validation**: The config is validated with Zod before use.
|
||||
* An `OpenTelemetryError` with code `OTEL.INVALID_CONFIG` is thrown for any
|
||||
* schema violation.
|
||||
*
|
||||
* **Current implementation**: Always returns a `NoopSdk`. Real SDK
|
||||
* implementations (e.g. `@opentelemetry/sdk-node`) require additional packages
|
||||
* that are not part of this package's dependency set. The intended integration
|
||||
* pattern is:
|
||||
*
|
||||
* 1. Application packages install `@opentelemetry/sdk-node` (or equivalent).
|
||||
* 2. They extend or wrap `NoopSdk` with real provider registration.
|
||||
* 3. They call `createSdk(config)` as the config-validation + lifecycle entry
|
||||
* point, then swap out the returned instance with their real SDK.
|
||||
*
|
||||
* **Development logging**: When `process` is available and
|
||||
* `process.env.NODE_ENV` is `"development"`, the validated config is logged to
|
||||
* `console.debug` to assist local debugging without leaking secrets in
|
||||
* production.
|
||||
*
|
||||
* @param config - Raw OTel configuration to validate and apply.
|
||||
* @returns An `OtelSdk` instance (currently always `NoopSdk`).
|
||||
* @throws {OpenTelemetryError} With code `OTEL.INVALID_CONFIG` when `config`
|
||||
* fails Zod validation.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createSdk } from "@suaveplan/opentelemetry/sdk";
|
||||
*
|
||||
* const sdk = createSdk({
|
||||
* serviceName: "payment-service",
|
||||
* serviceVersion: "1.0.0",
|
||||
* environment: "production",
|
||||
* traces: { exporter: "otlp-http", exporterUrl: "https://otel.example.com" },
|
||||
* });
|
||||
*
|
||||
* await sdk.start();
|
||||
* const tracer = sdk.getTracer("payment-service");
|
||||
* ```
|
||||
*/
|
||||
export function createSdk(config: OtelConfig): OtelSdk {
|
||||
const result = OtelConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
throw new OpenTelemetryError(
|
||||
OTEL_CODES.INVALID_CONFIG,
|
||||
"opentelemetry.invalid_config",
|
||||
{
|
||||
context: {
|
||||
issues: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof process !== "undefined" &&
|
||||
process.env["NODE_ENV"] === "development"
|
||||
) {
|
||||
console.debug(
|
||||
"[opentelemetry] createSdk config:",
|
||||
JSON.stringify(result.data, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
return new NoopSdk();
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
OpenTelemetryError,
|
||||
OTEL_CODES,
|
||||
type OtelCode,
|
||||
} from "./opentelemetry-error";
|
||||
@@ -0,0 +1,101 @@
|
||||
# opentelemetry-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the stable error class and code registry for all `@suaveplan/opentelemetry` failures. Every error thrown by this package is an `OpenTelemetryError` instance with a `code` drawn from `OTEL_CODES`, enabling callers to branch on machine-readable codes rather than string-matching error messages.
|
||||
|
||||
The code registry follows the `defineCodes` convention shared across the project: a frozen record mapping short reason keys to fully-qualified dot-separated strings (`OTEL.<REASON>`), narrowing the `TCode` type parameter so TypeScript knows the exact union at compile time.
|
||||
|
||||
## Features
|
||||
|
||||
- Four stable error codes covering the full SDK lifecycle: initialization, shutdown, configuration, and state guard
|
||||
- Extends `SuaveplanError` for cause-chain traversal, wire-format serialization, OTel span-event adapter, and redaction support inherited from `@suaveplan/error`
|
||||
- Fully narrowed `TCode` type — `instanceof OpenTelemetryError` narrows `.code` to `OtelCode`
|
||||
- Frozen code registry prevents accidental mutation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
|
||||
|
||||
throw new OpenTelemetryError(
|
||||
OTEL_CODES.INIT_FAILED,
|
||||
"opentelemetry.init_failed"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
|
||||
|
||||
try {
|
||||
await provider.start();
|
||||
} catch (cause) {
|
||||
throw new OpenTelemetryError(
|
||||
OTEL_CODES.INIT_FAILED,
|
||||
"opentelemetry.init_failed",
|
||||
{
|
||||
cause: cause instanceof Error ? cause : new Error(String(cause)),
|
||||
context: { provider: "otlp-http", endpoint: "https://otel.example.com" },
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Catching the code:
|
||||
|
||||
```ts
|
||||
import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
|
||||
|
||||
try {
|
||||
sdk.getTracer("my-service");
|
||||
} catch (err) {
|
||||
if (err instanceof OpenTelemetryError && err.code === OTEL_CODES.NOT_STARTED) {
|
||||
console.warn("SDK not yet started — call sdk.start() first");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `OTEL_CODES`
|
||||
|
||||
```ts
|
||||
const OTEL_CODES: {
|
||||
readonly INIT_FAILED: "OTEL.INIT_FAILED";
|
||||
readonly SHUTDOWN_FAILED: "OTEL.SHUTDOWN_FAILED";
|
||||
readonly INVALID_CONFIG: "OTEL.INVALID_CONFIG";
|
||||
readonly NOT_STARTED: "OTEL.NOT_STARTED";
|
||||
};
|
||||
```
|
||||
|
||||
Frozen record mapping short reason keys to fully-qualified `OTEL.*` code strings.
|
||||
|
||||
### `OtelCode`
|
||||
|
||||
```ts
|
||||
type OtelCode = "OTEL.INIT_FAILED" | "OTEL.SHUTDOWN_FAILED" | "OTEL.INVALID_CONFIG" | "OTEL.NOT_STARTED";
|
||||
```
|
||||
|
||||
Union of all valid OTel error codes.
|
||||
|
||||
### `OpenTelemetryError`
|
||||
|
||||
```ts
|
||||
class OpenTelemetryError extends SuaveplanError<OtelCode> {
|
||||
constructor(code: OtelCode, messageKey: string, options?: SuaveplanErrorOptions): void;
|
||||
}
|
||||
```
|
||||
|
||||
- `code` — one of the `OTEL_CODES` values; available as `.code` on the thrown instance
|
||||
- `messageKey` — i18n resolver key (falls back to the key itself when no resolver is registered)
|
||||
- `options.cause` — original error to attach as cause
|
||||
- `options.context` — arbitrary key/value bag for structured logging
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is marked with a biome suppress comment for `noUselessConstructor` because the sole purpose is to widen the `protected` base constructor to `public` — a required pattern across all `@suaveplan/*` error subclasses. The base `SuaveplanError` sets `this.name = new.target.name` automatically, so `error.name` will be `"OpenTelemetryError"` without needing a manual assignment.
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { OpenTelemetryError, OTEL_CODES } from "./opentelemetry-error.js";
|
||||
|
||||
describe("OpenTelemetryError", () => {
|
||||
describe("OTEL_CODES", () => {
|
||||
it("contains all four expected codes", { timeout: 5000 }, () => {
|
||||
expect(OTEL_CODES.INIT_FAILED).toBe("OTEL.INIT_FAILED");
|
||||
expect(OTEL_CODES.SHUTDOWN_FAILED).toBe("OTEL.SHUTDOWN_FAILED");
|
||||
expect(OTEL_CODES.INVALID_CONFIG).toBe("OTEL.INVALID_CONFIG");
|
||||
expect(OTEL_CODES.NOT_STARTED).toBe("OTEL.NOT_STARTED");
|
||||
});
|
||||
|
||||
it("is frozen (immutable)", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(OTEL_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenTelemetryError", () => {
|
||||
it("is an instance of Error", { timeout: 5000 }, () => {
|
||||
const err = new OpenTelemetryError(
|
||||
OTEL_CODES.INIT_FAILED,
|
||||
"init failed"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("exposes the code property", { timeout: 5000 }, () => {
|
||||
const err = new OpenTelemetryError(
|
||||
OTEL_CODES.SHUTDOWN_FAILED,
|
||||
"shutdown failed"
|
||||
);
|
||||
expect(err.code).toBe("OTEL.SHUTDOWN_FAILED");
|
||||
});
|
||||
|
||||
it("accepts a cause option", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new OpenTelemetryError(
|
||||
OTEL_CODES.INIT_FAILED,
|
||||
"init failed",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("sets error name to OpenTelemetryError", { timeout: 5000 }, () => {
|
||||
const err = new OpenTelemetryError(
|
||||
OTEL_CODES.INVALID_CONFIG,
|
||||
"bad config"
|
||||
);
|
||||
expect(err.name).toBe("OpenTelemetryError");
|
||||
});
|
||||
|
||||
it("can be caught with instanceof", { timeout: 5000 }, () => {
|
||||
try {
|
||||
throw new OpenTelemetryError(
|
||||
OTEL_CODES.NOT_STARTED,
|
||||
"not started"
|
||||
);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenTelemetryError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
if (err instanceof OpenTelemetryError) {
|
||||
expect(err.code).toBe(OTEL_CODES.NOT_STARTED);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes context when provided", { timeout: 5000 }, () => {
|
||||
const err = new OpenTelemetryError(
|
||||
OTEL_CODES.INVALID_CONFIG,
|
||||
"bad config",
|
||||
{ context: { field: "serviceName", issue: "empty" } }
|
||||
);
|
||||
expect(err.context).toEqual({
|
||||
field: "serviceName",
|
||||
issue: "empty",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error code registry for `@suaveplan/opentelemetry`.
|
||||
*
|
||||
* Four top-level codes covering the SDK lifecycle and configuration:
|
||||
* - `OTEL.INIT_FAILED` — SDK initialization failed
|
||||
* - `OTEL.SHUTDOWN_FAILED` — SDK shutdown failed
|
||||
* - `OTEL.INVALID_CONFIG` — configuration schema validation error
|
||||
* - `OTEL.NOT_STARTED` — operation attempted before SDK is started
|
||||
*/
|
||||
export const OTEL_CODES = defineCodes("OTEL", [
|
||||
"INIT_FAILED",
|
||||
"SHUTDOWN_FAILED",
|
||||
"INVALID_CONFIG",
|
||||
"NOT_STARTED",
|
||||
] as const);
|
||||
|
||||
export type OtelCode = (typeof OTEL_CODES)[keyof typeof OTEL_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/opentelemetry` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` with a strongly-typed `code` drawn from
|
||||
* `OTEL_CODES`, giving callers narrow `instanceof` discrimination and
|
||||
* a stable machine-readable code for logging/alerting.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { OpenTelemetryError, OTEL_CODES } from "@suaveplan/opentelemetry/errors";
|
||||
*
|
||||
* throw new OpenTelemetryError(
|
||||
* OTEL_CODES.INIT_FAILED,
|
||||
* "opentelemetry.init_failed",
|
||||
* { cause: originalError }
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class OpenTelemetryError extends SuaveplanError<OtelCode> {
|
||||
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
|
||||
public constructor(
|
||||
code: OtelCode,
|
||||
messageKey: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
}
|
||||
}
|
||||
55
packages/observability/monitor/opentelemetry/src/index.ts
Normal file
55
packages/observability/monitor/opentelemetry/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Errors
|
||||
|
||||
// Propagation
|
||||
export {
|
||||
buildTraceParent,
|
||||
extractTraceContext,
|
||||
getActiveSpanIds,
|
||||
injectTraceContext,
|
||||
parseTraceParent,
|
||||
} from "./core/propagation/propagation.js";
|
||||
// SDK
|
||||
export { NoopSdk, type OtelSdk } from "./core/sdk/noop-sdk.js";
|
||||
export { createSdk } from "./core/sdk/sdk-factory.js";
|
||||
export type { OtelCode } from "./errors/opentelemetry-error.js";
|
||||
export {
|
||||
OpenTelemetryError,
|
||||
OTEL_CODES,
|
||||
} from "./errors/opentelemetry-error.js";
|
||||
export type {
|
||||
ExporterType,
|
||||
LogsConfig,
|
||||
MetricsConfig,
|
||||
OtelConfig,
|
||||
PropagatorType,
|
||||
SamplerType,
|
||||
TracesConfig,
|
||||
} from "./schemas/otel-config.schema.js";
|
||||
// Schemas / Types
|
||||
export {
|
||||
ExporterTypeSchema,
|
||||
LogsConfigSchema,
|
||||
MetricsConfigSchema,
|
||||
OtelConfigSchema,
|
||||
PropagatorTypeSchema,
|
||||
SamplerTypeSchema,
|
||||
TracesConfigSchema,
|
||||
} from "./schemas/otel-config.schema.js";
|
||||
|
||||
// Metrics utilities
|
||||
export {
|
||||
createCounter,
|
||||
createGauge,
|
||||
createHistogram,
|
||||
recordDuration,
|
||||
} from "./utils/metrics-utils.js";
|
||||
|
||||
// Resource
|
||||
export { createResource } from "./utils/resource.js";
|
||||
// Span utilities
|
||||
export {
|
||||
addAttributes,
|
||||
getTraceContext,
|
||||
recordException,
|
||||
withSpan,
|
||||
} from "./utils/span-utils.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
type ExporterType,
|
||||
ExporterTypeSchema,
|
||||
type LogsConfig,
|
||||
LogsConfigSchema,
|
||||
type MetricsConfig,
|
||||
MetricsConfigSchema,
|
||||
type OtelConfig,
|
||||
OtelConfigSchema,
|
||||
type PropagatorType,
|
||||
PropagatorTypeSchema,
|
||||
type SamplerType,
|
||||
SamplerTypeSchema,
|
||||
type TracesConfig,
|
||||
TracesConfigSchema,
|
||||
} from "./otel-config.schema";
|
||||
@@ -0,0 +1,159 @@
|
||||
# otel-config.schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the Zod schemas and inferred TypeScript types that define the complete OpenTelemetry SDK configuration contract for `@suaveplan/opentelemetry`. All runtime configuration objects are validated against these schemas before they reach any SDK lifecycle code, ensuring that schema violations surface as structured `OpenTelemetryError` instances rather than silent misbehavior or runtime crashes.
|
||||
|
||||
The schemas serve as the single source of truth for what constitutes a valid `OtelConfig`. Any change to the accepted configuration shape is made here first, and all downstream code (e.g. `createSdk`, test fixtures) derives from these exports.
|
||||
|
||||
## Features
|
||||
|
||||
- **`ExporterTypeSchema`** — enum of the four supported exporter backends: `console`, `otlp-http`, `otlp-grpc`, and `noop`.
|
||||
- **`SamplerTypeSchema`** — enum of four trace-sampling strategies: `always-on`, `always-off`, `trace-id-ratio`, and `parent-based`.
|
||||
- **`PropagatorTypeSchema`** — enum of four context-propagation formats: `w3c-tracecontext`, `baggage`, `b3`, and `b3-multi`.
|
||||
- **`TracesConfigSchema`** — optional per-signal object covering exporter, URL, sampler type and rate, propagators list, batch size, and flush interval.
|
||||
- **`MetricsConfigSchema`** — optional metrics signal object covering exporter, URL, and export interval.
|
||||
- **`LogsConfigSchema`** — optional logs signal object covering exporter and URL.
|
||||
- **`OtelConfigSchema`** — top-level schema requiring `serviceName` and allowing optional `serviceVersion`, `environment`, and per-signal sub-configs.
|
||||
- **Inferred TypeScript types** — all schemas export a corresponding `z.infer<>` type alias so callers can use fully-typed config objects without re-declaring shapes.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { OtelConfigSchema } from "@suaveplan/opentelemetry";
|
||||
|
||||
const result = OtelConfigSchema.safeParse({
|
||||
serviceName: "my-service",
|
||||
serviceVersion: "1.0.0",
|
||||
environment: "production",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.error.issues);
|
||||
} else {
|
||||
const config = result.data; // typed as OtelConfig
|
||||
console.log(config.serviceName); // "my-service"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
Using the schema to validate a rich configuration that covers all three signals, then feeding the validated data into `createSdk`:
|
||||
|
||||
```ts
|
||||
import { createSdk, OtelConfigSchema, type OtelConfig } from "@suaveplan/opentelemetry";
|
||||
|
||||
const raw: unknown = {
|
||||
serviceName: "payment-service",
|
||||
serviceVersion: "2.3.1",
|
||||
environment: "staging",
|
||||
traces: {
|
||||
exporter: "otlp-http",
|
||||
exporterUrl: "https://otel.example.com/v1/traces",
|
||||
sampler: "trace-id-ratio",
|
||||
sampleRate: 0.25,
|
||||
propagators: ["w3c-tracecontext", "baggage"],
|
||||
batchSize: 512,
|
||||
flushInterval: 5000,
|
||||
},
|
||||
metrics: {
|
||||
exporter: "otlp-http",
|
||||
exporterUrl: "https://otel.example.com/v1/metrics",
|
||||
exportIntervalMs: 30000,
|
||||
},
|
||||
logs: {
|
||||
exporter: "console",
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = OtelConfigSchema.parse(raw); // throws ZodError on failure
|
||||
const sdk = createSdk(parsed as OtelConfig);
|
||||
await sdk.start();
|
||||
```
|
||||
|
||||
You can also validate individual sub-schemas independently:
|
||||
|
||||
```ts
|
||||
import { TracesConfigSchema } from "@suaveplan/opentelemetry";
|
||||
|
||||
const tracesResult = TracesConfigSchema.safeParse({
|
||||
exporter: "noop",
|
||||
sampler: "always-off",
|
||||
});
|
||||
// tracesResult.success === true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ExporterTypeSchema`
|
||||
|
||||
`z.ZodEnum` — validates that a value is one of `"console" | "otlp-http" | "otlp-grpc" | "noop"`.
|
||||
|
||||
### `SamplerTypeSchema`
|
||||
|
||||
`z.ZodEnum` — validates that a value is one of `"always-on" | "always-off" | "trace-id-ratio" | "parent-based"`.
|
||||
|
||||
### `PropagatorTypeSchema`
|
||||
|
||||
`z.ZodEnum` — validates that a value is one of `"w3c-tracecontext" | "baggage" | "b3" | "b3-multi"`.
|
||||
|
||||
### `TracesConfigSchema`
|
||||
|
||||
`z.ZodObject` — all fields optional:
|
||||
|
||||
- `exporter?: ExporterType`
|
||||
- `exporterUrl?: string` (validated as URL)
|
||||
- `sampler?: SamplerType`
|
||||
- `sampleRate?: number` (0.0–1.0 inclusive)
|
||||
- `propagators?: PropagatorType[]`
|
||||
- `batchSize?: number` (positive integer)
|
||||
- `flushInterval?: number` (positive integer, milliseconds)
|
||||
|
||||
### `MetricsConfigSchema`
|
||||
|
||||
`z.ZodObject` — all fields optional:
|
||||
|
||||
- `exporter?: ExporterType`
|
||||
- `exporterUrl?: string` (validated as URL)
|
||||
- `exportIntervalMs?: number` (positive integer, milliseconds)
|
||||
|
||||
### `LogsConfigSchema`
|
||||
|
||||
`z.ZodObject` — all fields optional:
|
||||
|
||||
- `exporter?: ExporterType`
|
||||
- `exporterUrl?: string` (validated as URL)
|
||||
|
||||
### `OtelConfigSchema`
|
||||
|
||||
`z.ZodObject` — top-level config schema:
|
||||
|
||||
- `serviceName: string` (required, minimum length 1)
|
||||
- `serviceVersion?: string`
|
||||
- `environment?: string`
|
||||
- `traces?: TracesConfig`
|
||||
- `metrics?: MetricsConfig`
|
||||
- `logs?: LogsConfig`
|
||||
|
||||
### Type Aliases
|
||||
|
||||
- `OtelConfig` — `z.infer<typeof OtelConfigSchema>`
|
||||
- `TracesConfig` — `z.infer<typeof TracesConfigSchema>`
|
||||
- `MetricsConfig` — `z.infer<typeof MetricsConfigSchema>`
|
||||
- `LogsConfig` — `z.infer<typeof LogsConfigSchema>`
|
||||
- `ExporterType` — `z.infer<typeof ExporterTypeSchema>`
|
||||
- `SamplerType` — `z.infer<typeof SamplerTypeSchema>`
|
||||
- `PropagatorType` — `z.infer<typeof PropagatorTypeSchema>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema file is intentionally kept free of business logic — it contains only Zod definitions and their `z.infer<>` type exports. Validation errors from `OtelConfigSchema.safeParse` are surfaced to callers by `createSdk`, which maps them into `OpenTelemetryError` instances with the `OTEL.INVALID_CONFIG` code.
|
||||
|
||||
The `sampleRate` field uses `z.number().min(0).max(1)` with both bounds inclusive so that `0.0` (never sample) and `1.0` (always sample) are valid — these boundary values are important for feature-flag-driven sampling.
|
||||
|
||||
All URL fields use `z.string().url()` rather than a looser regex to ensure that only well-formed absolute URLs are accepted, preventing misconfigured exporter endpoints from reaching the network layer at runtime.
|
||||
|
||||
The enum schemas (`ExporterTypeSchema`, `SamplerTypeSchema`, `PropagatorTypeSchema`) are exported as named constants rather than inlined inside the parent schemas so that they can be reused independently for validation of partial configs or for driving UI option lists without pulling in the full parent schema.
|
||||
@@ -0,0 +1,87 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Supported exporter backends for traces, metrics, and logs. */
|
||||
export const ExporterTypeSchema = z.enum([
|
||||
"console",
|
||||
"otlp-http",
|
||||
"otlp-grpc",
|
||||
"noop",
|
||||
]);
|
||||
|
||||
/** Sampling strategy for trace decisions. */
|
||||
export const SamplerTypeSchema = z.enum([
|
||||
"always-on",
|
||||
"always-off",
|
||||
"trace-id-ratio",
|
||||
"parent-based",
|
||||
]);
|
||||
|
||||
/** Context-propagation format for distributed tracing. */
|
||||
export const PropagatorTypeSchema = z.enum([
|
||||
"w3c-tracecontext",
|
||||
"baggage",
|
||||
"b3",
|
||||
"b3-multi",
|
||||
]);
|
||||
|
||||
/** Zod schema for trace configuration within {@link OtelConfigSchema}. */
|
||||
export const TracesConfigSchema = z.object({
|
||||
exporter: ExporterTypeSchema.optional(),
|
||||
exporterUrl: z.string().url().optional(),
|
||||
sampler: SamplerTypeSchema.optional(),
|
||||
/** Sample rate from 0.0 to 1.0, only relevant for `trace-id-ratio`. */
|
||||
sampleRate: z.number().min(0).max(1).optional(),
|
||||
propagators: z.array(PropagatorTypeSchema).optional(),
|
||||
batchSize: z.number().int().positive().optional(),
|
||||
flushInterval: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for metrics configuration within {@link OtelConfigSchema}. */
|
||||
export const MetricsConfigSchema = z.object({
|
||||
exporter: ExporterTypeSchema.optional(),
|
||||
exporterUrl: z.string().url().optional(),
|
||||
/** Interval in milliseconds between metric exports. Defaults to 60 000 ms. */
|
||||
exportIntervalMs: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
/** Zod schema for logs configuration within {@link OtelConfigSchema}. */
|
||||
export const LogsConfigSchema = z.object({
|
||||
exporter: ExporterTypeSchema.optional(),
|
||||
exporterUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Zod schema for the top-level OTel SDK configuration object.
|
||||
*
|
||||
* Validates service identity fields and the optional per-signal
|
||||
* (traces / metrics / logs) sub-configuration.
|
||||
*/
|
||||
export const OtelConfigSchema = z.object({
|
||||
serviceName: z.string().min(1),
|
||||
serviceVersion: z.string().optional(),
|
||||
environment: z.string().optional(),
|
||||
traces: TracesConfigSchema.optional(),
|
||||
metrics: MetricsConfigSchema.optional(),
|
||||
logs: LogsConfigSchema.optional(),
|
||||
});
|
||||
|
||||
/** Inferred type from {@link OtelConfigSchema}. */
|
||||
export type OtelConfig = z.infer<typeof OtelConfigSchema>;
|
||||
|
||||
/** Inferred type from {@link TracesConfigSchema}. */
|
||||
export type TracesConfig = z.infer<typeof TracesConfigSchema>;
|
||||
|
||||
/** Inferred type from {@link MetricsConfigSchema}. */
|
||||
export type MetricsConfig = z.infer<typeof MetricsConfigSchema>;
|
||||
|
||||
/** Inferred type from {@link LogsConfigSchema}. */
|
||||
export type LogsConfig = z.infer<typeof LogsConfigSchema>;
|
||||
|
||||
/** Supported exporter backend literals. */
|
||||
export type ExporterType = z.infer<typeof ExporterTypeSchema>;
|
||||
|
||||
/** Sampling strategy literals. */
|
||||
export type SamplerType = z.infer<typeof SamplerTypeSchema>;
|
||||
|
||||
/** Context-propagation format literals. */
|
||||
export type PropagatorType = z.infer<typeof PropagatorTypeSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
createCounter,
|
||||
createGauge,
|
||||
createHistogram,
|
||||
recordDuration,
|
||||
} from "./metrics-utils";
|
||||
export { createResource } from "./resource";
|
||||
export {
|
||||
addAttributes,
|
||||
buildSpanContextObject,
|
||||
getTraceContext,
|
||||
recordException,
|
||||
withSpan,
|
||||
} from "./span-utils";
|
||||
@@ -0,0 +1,108 @@
|
||||
# metrics-utils
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides thin factory and recording helpers that wrap the `@opentelemetry/api` metrics API with sensible defaults and ergonomic call signatures. These utilities reduce boilerplate at call sites, enforce consistent instrument creation patterns, and make the most common metrics pattern — recording a duration — a single-line operation.
|
||||
|
||||
Like all utilities in this package, these depend only on `@opentelemetry/api`, so they work equally well with any SDK implementation or the built-in no-op fallback used in tests and library code.
|
||||
|
||||
## Features
|
||||
|
||||
- `createCounter` — monotonically increasing counter for totals (requests, errors, events)
|
||||
- `createHistogram` — value distribution tracking for latencies and sizes (supports unit string)
|
||||
- `createGauge` — observable gauge for current-value snapshots (memory, queue depth)
|
||||
- `recordDuration` — records elapsed `Date.now() - startMs` on a histogram in one call
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createCounter, createHistogram, recordDuration } from "@suaveplan/opentelemetry/metrics-utils";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("my-service");
|
||||
|
||||
const errors = createCounter(meter, "app.errors", "Total application errors");
|
||||
errors.add(1, { "error.type": "validation" });
|
||||
|
||||
const latency = createHistogram(meter, "app.request.duration", "Request duration", "ms");
|
||||
const start = Date.now();
|
||||
await handleRequest();
|
||||
recordDuration(latency, start, { "http.route": "/api/users" });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createGauge, createHistogram, createCounter } from "@suaveplan/opentelemetry/metrics-utils";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
const meter = metrics.getMeter("worker-service", "1.0.0");
|
||||
|
||||
// Observable gauge for current queue depth (polled at collection time)
|
||||
const queueGauge = createGauge(meter, "worker.queue.depth", "Current items in the work queue");
|
||||
let currentQueueDepth = 0;
|
||||
queueGauge.addCallback((result) => {
|
||||
result.observe(currentQueueDepth, { "queue.name": "orders" });
|
||||
});
|
||||
|
||||
// Histogram with explicit unit
|
||||
const processingTime = createHistogram(
|
||||
meter,
|
||||
"worker.job.processing_time",
|
||||
"Time spent processing each job",
|
||||
"ms"
|
||||
);
|
||||
|
||||
// Counter for tracking retries
|
||||
const retryCounter = createCounter(meter, "worker.job.retries", "Total job retry attempts");
|
||||
|
||||
async function processJob(job: Job) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await job.execute();
|
||||
} catch {
|
||||
retryCounter.add(1, { "job.type": job.type });
|
||||
throw;
|
||||
} finally {
|
||||
recordDuration(processingTime, start, { "job.type": job.type });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createCounter(meter, name, description?)`
|
||||
|
||||
```ts
|
||||
function createCounter(meter: Meter, name: string, description?: string): Counter
|
||||
```
|
||||
|
||||
Creates a synchronous, monotonically increasing counter. Call `.add(value, attributes?)` to increment.
|
||||
|
||||
### `createHistogram(meter, name, description?, unit?)`
|
||||
|
||||
```ts
|
||||
function createHistogram(meter: Meter, name: string, description?: string, unit?: string): Histogram
|
||||
```
|
||||
|
||||
Creates a synchronous histogram instrument. Call `.record(value, attributes?)` to record an observation. `unit` follows the OTel unit convention (e.g. `"ms"`, `"By"`, `"1"`).
|
||||
|
||||
### `createGauge(meter, name, description?)`
|
||||
|
||||
```ts
|
||||
function createGauge(meter: Meter, name: string, description?: string): ObservableGauge
|
||||
```
|
||||
|
||||
Creates an asynchronous observable gauge. Register a callback via `.addCallback(fn)` to supply the current value at collection time.
|
||||
|
||||
### `recordDuration(histogram, startMs, attributes?)`
|
||||
|
||||
```ts
|
||||
function recordDuration(histogram: Histogram, startMs: number, attributes?: Attributes): void
|
||||
```
|
||||
|
||||
Records `Date.now() - startMs` on `histogram`. `startMs` should be captured with `Date.now()` immediately before the operation being measured.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All factory functions pass the `description` to the underlying API even when it is `undefined` — the API ignores undefined values gracefully. The decision to use `createObservableGauge` (asynchronous) over `createGauge` (synchronous, upDownCounter-style) aligns with the OTel recommendation that current-value snapshots should use the observable API so the SDK can poll them at collection time rather than storing every intermediate value. `recordDuration` always uses `Date.now()` as the wall-clock source; for high-resolution measurement use `performance.now()` and convert to milliseconds before passing as `startMs`.
|
||||
@@ -0,0 +1,138 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createCounter,
|
||||
createGauge,
|
||||
createHistogram,
|
||||
recordDuration,
|
||||
} from "./metrics-utils.js";
|
||||
|
||||
describe("metrics-utils", () => {
|
||||
const meter = metrics.getMeter("test-meter");
|
||||
|
||||
describe("createCounter", () => {
|
||||
it("returns a valid OTel Counter object", { timeout: 5000 }, () => {
|
||||
const counter = createCounter(meter, "test.counter");
|
||||
expect(counter).toBeDefined();
|
||||
expect(typeof counter.add).toBe("function");
|
||||
});
|
||||
|
||||
it("returns a counter with a description", { timeout: 5000 }, () => {
|
||||
const counter = createCounter(
|
||||
meter,
|
||||
"test.counter.desc",
|
||||
"A test counter"
|
||||
);
|
||||
expect(counter).toBeDefined();
|
||||
expect(typeof counter.add).toBe("function");
|
||||
});
|
||||
|
||||
it("does not throw when add is called", { timeout: 5000 }, () => {
|
||||
const counter = createCounter(meter, "test.counter.add");
|
||||
expect(() => counter.add(1)).not.toThrow();
|
||||
expect(() => counter.add(5, { label: "value" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHistogram", () => {
|
||||
it("returns a valid OTel Histogram object", { timeout: 5000 }, () => {
|
||||
const histogram = createHistogram(meter, "test.histogram");
|
||||
expect(histogram).toBeDefined();
|
||||
expect(typeof histogram.record).toBe("function");
|
||||
});
|
||||
|
||||
it("accepts description and unit", { timeout: 5000 }, () => {
|
||||
const histogram = createHistogram(
|
||||
meter,
|
||||
"test.histogram.units",
|
||||
"Duration",
|
||||
"ms"
|
||||
);
|
||||
expect(histogram).toBeDefined();
|
||||
expect(typeof histogram.record).toBe("function");
|
||||
});
|
||||
|
||||
it("does not throw when record is called", { timeout: 5000 }, () => {
|
||||
const histogram = createHistogram(meter, "test.histogram.record");
|
||||
expect(() => histogram.record(42)).not.toThrow();
|
||||
expect(() =>
|
||||
histogram.record(100, { operation: "query" })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createGauge", () => {
|
||||
it("returns a valid OTel ObservableGauge object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const gauge = createGauge(meter, "test.gauge");
|
||||
expect(gauge).toBeDefined();
|
||||
expect(typeof gauge.addCallback).toBe("function");
|
||||
});
|
||||
|
||||
it("accepts a description", { timeout: 5000 }, () => {
|
||||
const gauge = createGauge(
|
||||
meter,
|
||||
"test.gauge.desc",
|
||||
"Current value"
|
||||
);
|
||||
expect(gauge).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not throw when a callback is added", { timeout: 5000 }, () => {
|
||||
const gauge = createGauge(meter, "test.gauge.callback");
|
||||
expect(() => gauge.addCallback((_result) => {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordDuration", () => {
|
||||
it("records without throwing", { timeout: 5000 }, () => {
|
||||
const histogram = createHistogram(meter, "test.duration");
|
||||
const start = Date.now() - 100;
|
||||
expect(() => recordDuration(histogram, start)).not.toThrow();
|
||||
});
|
||||
|
||||
it("records with attributes without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const histogram = createHistogram(meter, "test.duration.attrs");
|
||||
const start = Date.now() - 50;
|
||||
expect(() =>
|
||||
recordDuration(histogram, start, { route: "/api" })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes a non-negative duration to the histogram", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const recorded: number[] = [];
|
||||
const fakeHistogram = {
|
||||
record: (value: number) => {
|
||||
recorded.push(value);
|
||||
},
|
||||
} as Parameters<typeof recordDuration>[0];
|
||||
|
||||
const start = Date.now() - 200;
|
||||
recordDuration(fakeHistogram, start);
|
||||
|
||||
expect(recorded).toHaveLength(1);
|
||||
expect(recorded[0]).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("passes attributes to the underlying record call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const calls: Array<{ value: number; attrs: unknown }> = [];
|
||||
const fakeHistogram = {
|
||||
record: (value: number, attrs: unknown) => {
|
||||
calls.push({ value, attrs });
|
||||
},
|
||||
} as Parameters<typeof recordDuration>[0];
|
||||
|
||||
recordDuration(fakeHistogram, Date.now() - 10, { op: "save" });
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.attrs).toEqual({ op: "save" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
Attributes,
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
ObservableGauge,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Create a monotonically increasing counter instrument on the given meter.
|
||||
*
|
||||
* Counters track cumulative totals that never decrease (e.g. total requests,
|
||||
* total errors). Use a histogram instead when you need distribution data.
|
||||
*
|
||||
* @param meter - OTel `Meter` instance to create the counter on.
|
||||
* @param name - Instrument name following the OTel naming convention (`<domain>.<metric>`).
|
||||
* @param description - Optional human-readable description shown in metric explorers.
|
||||
* @returns An OTel `Counter` instrument.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const requestCounter = createCounter(meter, "http.server.request_count", "Total HTTP requests");
|
||||
* requestCounter.add(1, { "http.method": "GET" });
|
||||
* ```
|
||||
*/
|
||||
export function createCounter(
|
||||
meter: Meter,
|
||||
name: string,
|
||||
description?: string
|
||||
): Counter {
|
||||
return meter.createCounter(
|
||||
name,
|
||||
description !== undefined ? { description } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a histogram instrument on the given meter.
|
||||
*
|
||||
* Histograms record the distribution of values (e.g. request durations,
|
||||
* payload sizes). Use this over a counter when percentile data matters.
|
||||
*
|
||||
* @param meter - OTel `Meter` instance.
|
||||
* @param name - Instrument name.
|
||||
* @param description - Optional description.
|
||||
* @param unit - Optional unit string (e.g. `"ms"`, `"By"`).
|
||||
* @returns An OTel `Histogram` instrument.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const latency = createHistogram(meter, "http.server.duration", "Request duration", "ms");
|
||||
* latency.record(42, { "http.route": "/api/users" });
|
||||
* ```
|
||||
*/
|
||||
export function createHistogram(
|
||||
meter: Meter,
|
||||
name: string,
|
||||
description?: string,
|
||||
unit?: string
|
||||
): Histogram {
|
||||
const opts: { description?: string; unit?: string } = {};
|
||||
if (description !== undefined) opts.description = description;
|
||||
if (unit !== undefined) opts.unit = unit;
|
||||
return meter.createHistogram(name, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an observable (asynchronous) gauge instrument on the given meter.
|
||||
*
|
||||
* Observable gauges report the current value of a resource at collection time
|
||||
* (e.g. current queue depth, active connections). The callback is registered
|
||||
* separately via the `ObservableGauge.addCallback` API.
|
||||
*
|
||||
* @param meter - OTel `Meter` instance.
|
||||
* @param name - Instrument name.
|
||||
* @param description - Optional description.
|
||||
* @returns An OTel `ObservableGauge` instrument.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const gauge = createGauge(meter, "system.memory.usage", "Current memory usage in bytes");
|
||||
* gauge.addCallback((result) => {
|
||||
* result.observe(process.memoryUsage().heapUsed, { type: "heap" });
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createGauge(
|
||||
meter: Meter,
|
||||
name: string,
|
||||
description?: string
|
||||
): ObservableGauge {
|
||||
return meter.createObservableGauge(
|
||||
name,
|
||||
description !== undefined ? { description } : {}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the elapsed duration from a start timestamp to now.
|
||||
*
|
||||
* Computes `Date.now() - startMs` and records the result on `histogram`.
|
||||
* This pattern avoids repeating the subtraction at every call site and
|
||||
* centralises the choice of `Date.now()` as the wall-clock source.
|
||||
*
|
||||
* @param histogram - The histogram instrument to record on.
|
||||
* @param startMs - Start timestamp in milliseconds (from `Date.now()`).
|
||||
* @param attributes - Optional attributes to attach to the observation.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const start = Date.now();
|
||||
* await processOrder(order);
|
||||
* recordDuration(latencyHistogram, start, { "order.type": order.type });
|
||||
* ```
|
||||
*/
|
||||
export function recordDuration(
|
||||
histogram: Histogram,
|
||||
startMs: number,
|
||||
attributes?: Attributes
|
||||
): void {
|
||||
histogram.record(Date.now() - startMs, attributes);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# resource
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds a frozen OpenTelemetry resource attribute map from service identity configuration fields. Resource attributes are attached to every span, metric, and log record produced by the SDK, providing the correlation anchor that ties telemetry signals back to a specific deployed service instance.
|
||||
|
||||
This utility abstracts the mapping from `OtelConfig` fields to OTEL Semantic Conventions keys (`service.name`, `service.version`, `deployment.environment`), so callers never have to remember which convention strings to use.
|
||||
|
||||
## Features
|
||||
|
||||
- Maps `serviceName` → `service.name` (always present)
|
||||
- Maps `serviceVersion` → `service.version` (omitted when undefined)
|
||||
- Maps `environment` → `deployment.environment` (omitted when undefined)
|
||||
- Returns a frozen `Attributes` object — safe to share across SDK, tests, and exporters
|
||||
- Zero side effects; pure function
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createResource } from "@suaveplan/opentelemetry/span-utils";
|
||||
|
||||
const attrs = createResource({ serviceName: "payment-service" });
|
||||
// { "service.name": "payment-service" }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createResource } from "@suaveplan/opentelemetry/span-utils";
|
||||
|
||||
const attrs = createResource({
|
||||
serviceName: "payment-service",
|
||||
serviceVersion: "2.1.0",
|
||||
environment: "production",
|
||||
});
|
||||
// {
|
||||
// "service.name": "payment-service",
|
||||
// "service.version": "2.1.0",
|
||||
// "deployment.environment": "production",
|
||||
// }
|
||||
|
||||
// Verify immutability
|
||||
Object.assign(attrs, { extra: "ignored" }); // throws in strict mode (frozen)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createResource(config)`
|
||||
|
||||
```ts
|
||||
function createResource(
|
||||
config: Pick<OtelConfig, "serviceName" | "serviceVersion" | "environment">
|
||||
): Attributes
|
||||
```
|
||||
|
||||
Builds and freezes an OTel resource attribute map.
|
||||
|
||||
- `config.serviceName` — required; mapped to `service.name`
|
||||
- `config.serviceVersion` — optional; mapped to `service.version` when present
|
||||
- `config.environment` — optional; mapped to `deployment.environment` when present
|
||||
|
||||
**Returns** a frozen `Attributes` object (`Record<string, AttributeValue>`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The object is frozen with `Object.freeze` to prevent accidental mutation after construction. Optional fields are only included when they are not `undefined` — this matches the OpenTelemetry convention where absent attributes should be omitted rather than set to empty strings. The function does not accept a full `OtelConfig` in order to remain decoupled from signal-level configuration and avoid pulling in Zod validation at call time.
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createResource } from "./resource.js";
|
||||
|
||||
describe("createResource", () => {
|
||||
it("includes service.name when provided", { timeout: 5000 }, () => {
|
||||
const attrs = createResource({ serviceName: "my-service" });
|
||||
expect(attrs["service.name"]).toBe("my-service");
|
||||
});
|
||||
|
||||
it("includes service.version when provided", { timeout: 5000 }, () => {
|
||||
const attrs = createResource({
|
||||
serviceName: "svc",
|
||||
serviceVersion: "2.0.0",
|
||||
});
|
||||
expect(attrs["service.version"]).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("omits service.version when not provided", { timeout: 5000 }, () => {
|
||||
const attrs = createResource({ serviceName: "svc" });
|
||||
expect("service.version" in attrs).toBe(false);
|
||||
});
|
||||
|
||||
it("includes deployment.environment when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const attrs = createResource({
|
||||
serviceName: "svc",
|
||||
environment: "production",
|
||||
});
|
||||
expect(attrs["deployment.environment"]).toBe("production");
|
||||
});
|
||||
|
||||
it("omits deployment.environment when not provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const attrs = createResource({ serviceName: "svc" });
|
||||
expect("deployment.environment" in attrs).toBe(false);
|
||||
});
|
||||
|
||||
it("includes all three fields when all are provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const attrs = createResource({
|
||||
serviceName: "payment-service",
|
||||
serviceVersion: "1.2.3",
|
||||
environment: "staging",
|
||||
});
|
||||
expect(attrs["service.name"]).toBe("payment-service");
|
||||
expect(attrs["service.version"]).toBe("1.2.3");
|
||||
expect(attrs["deployment.environment"]).toBe("staging");
|
||||
});
|
||||
|
||||
it("returns a frozen object", { timeout: 5000 }, () => {
|
||||
const attrs = createResource({ serviceName: "svc" });
|
||||
expect(Object.isFrozen(attrs)).toBe(true);
|
||||
});
|
||||
|
||||
it("returned object is not affected by mutations to the input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const config = { serviceName: "svc", serviceVersion: "1.0.0" };
|
||||
const attrs = createResource(config);
|
||||
config.serviceName = "mutated";
|
||||
expect(attrs["service.name"]).toBe("svc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Attributes } from "@opentelemetry/api";
|
||||
import type { OtelConfig } from "../schemas/otel-config.schema.js";
|
||||
|
||||
/**
|
||||
* Build an OTel resource attribute map from service identity configuration.
|
||||
*
|
||||
* The returned object conforms to `@opentelemetry/api`'s `Attributes` type and
|
||||
* maps directly to OTEL Semantic Conventions resource keys:
|
||||
* - `service.name` — required, from `config.serviceName`
|
||||
* - `service.version` — present only when `config.serviceVersion` is provided
|
||||
* - `deployment.environment` — present only when `config.environment` is provided
|
||||
*
|
||||
* @param config - Service identity fields from the top-level `OtelConfig`.
|
||||
* @returns A frozen `Attributes` map suitable for passing to an SDK resource.
|
||||
*/
|
||||
export function createResource(
|
||||
config: Pick<OtelConfig, "serviceName" | "serviceVersion" | "environment">
|
||||
): Attributes {
|
||||
const attrs: Attributes = {
|
||||
"service.name": config.serviceName,
|
||||
};
|
||||
|
||||
if (config.serviceVersion !== undefined) {
|
||||
attrs["service.version"] = config.serviceVersion;
|
||||
}
|
||||
|
||||
if (config.environment !== undefined) {
|
||||
attrs["deployment.environment"] = config.environment;
|
||||
}
|
||||
|
||||
return Object.freeze(attrs);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
# span-utils
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides ergonomic helper functions for working with OpenTelemetry spans. Rather than writing the same try/catch/finally/recordException/setStatus boilerplate in every traced function, callers use `withSpan` to declaratively wrap work in a span with correct error handling built in.
|
||||
|
||||
These utilities depend only on `@opentelemetry/api` and are therefore compatible with any OTel SDK implementation (or the no-op fallback), making them safe to import in library code that should not force a specific SDK on its consumers.
|
||||
|
||||
## Features
|
||||
|
||||
- `withSpan` — wraps an async function in an active span; records exceptions and ends the span unconditionally
|
||||
- `recordException` — normalizes any thrown value to an Error and records it with ERROR status
|
||||
- `addAttributes` — sets a bulk `Attributes` map on a span in a single call
|
||||
- `getTraceContext` — extracts `traceId`, `spanId`, and `traceFlags` from the currently active span
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { withSpan } from "@suaveplan/opentelemetry/span-utils";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
|
||||
const tracer = trace.getTracer("my-service");
|
||||
|
||||
const result = await withSpan(tracer, "user.fetch", async (span) => {
|
||||
span.setAttribute("user.id", userId);
|
||||
return await db.findUser(userId);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { withSpan, addAttributes, getTraceContext } from "@suaveplan/opentelemetry/span-utils";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
|
||||
const tracer = trace.getTracer("payment-service", "1.0.0");
|
||||
|
||||
async function processPayment(orderId: string, amount: number) {
|
||||
return withSpan(
|
||||
tracer,
|
||||
"payment.process",
|
||||
async (span) => {
|
||||
addAttributes(span, {
|
||||
"payment.order_id": orderId,
|
||||
"payment.amount": amount,
|
||||
"payment.currency": "USD",
|
||||
});
|
||||
|
||||
const result = await chargeGateway(orderId, amount);
|
||||
|
||||
const ctx = getTraceContext();
|
||||
console.log("trace:", ctx.traceId, "span:", ctx.spanId);
|
||||
|
||||
return result;
|
||||
},
|
||||
{ attributes: { "payment.initial": true } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `withSpan(tracer, name, fn, options?)`
|
||||
|
||||
```ts
|
||||
function withSpan<T>(
|
||||
tracer: Tracer,
|
||||
name: string,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
options?: { attributes?: Attributes }
|
||||
): Promise<T>
|
||||
```
|
||||
|
||||
Starts an active child span, calls `fn` with it, ends the span in `finally`, and records any exception + sets ERROR status before re-throwing.
|
||||
|
||||
### `recordException(span, error)`
|
||||
|
||||
```ts
|
||||
function recordException(span: Span, error: unknown): void
|
||||
```
|
||||
|
||||
Records `error` on `span` as a span event and sets `SpanStatusCode.ERROR`. Non-Error values are wrapped in `new Error(String(error))` before recording.
|
||||
|
||||
### `addAttributes(span, attrs)`
|
||||
|
||||
```ts
|
||||
function addAttributes(span: Span, attrs: Attributes): void
|
||||
```
|
||||
|
||||
Sets each key/value pair from `attrs` on the span. `undefined` values are skipped.
|
||||
|
||||
### `getTraceContext()`
|
||||
|
||||
```ts
|
||||
function getTraceContext(): { traceId?: string; spanId?: string; traceFlags?: number }
|
||||
```
|
||||
|
||||
Returns the active span's context fields. All fields are `undefined` when no span is active.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`withSpan` uses `tracer.startActiveSpan` so that child spans created inside `fn` are automatically parented to this span via the context API — no manual context propagation required. The `finally` block ensures `span.end()` is always called even when `fn` throws, which is the single most common OTel correctness bug in hand-written tracing code. `recordException` follows the OTel spec by calling the API's built-in `span.recordException` rather than manually building the event, ensuring the `exception.type`, `exception.message`, and `exception.stacktrace` attributes are populated correctly.
|
||||
@@ -0,0 +1,313 @@
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
addAttributes,
|
||||
buildSpanContextObject,
|
||||
getTraceContext,
|
||||
recordException,
|
||||
withSpan,
|
||||
} from "./span-utils.js";
|
||||
|
||||
describe("span-utils", () => {
|
||||
const tracer = trace.getTracer("test-tracer");
|
||||
|
||||
describe("withSpan", () => {
|
||||
it("calls fn and returns its result", { timeout: 5000 }, async () => {
|
||||
const result = await withSpan(
|
||||
tracer,
|
||||
"test.span",
|
||||
async (_span) => {
|
||||
return 42;
|
||||
}
|
||||
);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("passes the active span to fn", { timeout: 5000 }, async () => {
|
||||
let receivedSpan: unknown = null;
|
||||
await withSpan(tracer, "test.span", async (span) => {
|
||||
receivedSpan = span;
|
||||
});
|
||||
expect(receivedSpan).not.toBeNull();
|
||||
});
|
||||
|
||||
it("re-throws errors thrown by fn", { timeout: 5000 }, async () => {
|
||||
const error = new Error("test failure");
|
||||
await expect(
|
||||
withSpan(tracer, "test.span", async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toThrow("test failure");
|
||||
});
|
||||
|
||||
it("records exception on the span when fn throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const recordedExceptions: unknown[] = [];
|
||||
const fakeSpan = {
|
||||
recordException: (err: unknown) => {
|
||||
recordedExceptions.push(err);
|
||||
},
|
||||
setStatus: () => {},
|
||||
end: () => {},
|
||||
setAttribute: () => {},
|
||||
};
|
||||
|
||||
const patchedTracer = {
|
||||
startActiveSpan<T>(
|
||||
_name: string,
|
||||
_opts: unknown,
|
||||
fn: (span: typeof fakeSpan) => T
|
||||
): T {
|
||||
return fn(fakeSpan);
|
||||
},
|
||||
} as unknown as typeof tracer;
|
||||
|
||||
const error = new TypeError("boom");
|
||||
await expect(
|
||||
withSpan(patchedTracer, "err.span", async () => {
|
||||
throw error;
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(recordedExceptions).toHaveLength(1);
|
||||
expect(recordedExceptions[0]).toBe(error);
|
||||
});
|
||||
|
||||
it("sets initial attributes from options", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const setAttrs: Record<string, unknown> = {};
|
||||
const fakeSpan = {
|
||||
recordException: () => {},
|
||||
setStatus: () => {},
|
||||
end: () => {},
|
||||
setAttribute: (k: string, v: unknown) => {
|
||||
setAttrs[k] = v;
|
||||
},
|
||||
};
|
||||
|
||||
const patchedTracer = {
|
||||
startActiveSpan<T>(
|
||||
_name: string,
|
||||
opts: { attributes?: Record<string, unknown> },
|
||||
fn: (span: typeof fakeSpan) => T
|
||||
): T {
|
||||
for (const [k, v] of Object.entries(
|
||||
opts.attributes ?? {}
|
||||
)) {
|
||||
fakeSpan.setAttribute(k, v);
|
||||
}
|
||||
return fn(fakeSpan);
|
||||
},
|
||||
} as unknown as typeof tracer;
|
||||
|
||||
await withSpan(patchedTracer, "attr.span", async () => "ok", {
|
||||
attributes: { "test.key": "test-value" },
|
||||
});
|
||||
|
||||
expect(setAttrs["test.key"]).toBe("test-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordException", () => {
|
||||
it("sets span status to ERROR and records the exception for Error instances", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const events: unknown[] = [];
|
||||
let statusCode: unknown = null;
|
||||
|
||||
const span = {
|
||||
recordException: (err: unknown) => {
|
||||
events.push(err);
|
||||
},
|
||||
setStatus: (s: { code: unknown }) => {
|
||||
statusCode = s.code;
|
||||
},
|
||||
} as Parameters<typeof recordException>[0];
|
||||
|
||||
const err = new RangeError("out of range");
|
||||
recordException(span, err);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toBe(err);
|
||||
expect(statusCode).toBe(2); // SpanStatusCode.ERROR === 2
|
||||
});
|
||||
|
||||
it("does not throw for non-Error values", { timeout: 5000 }, () => {
|
||||
const span = {
|
||||
recordException: () => {},
|
||||
setStatus: () => {},
|
||||
} as Parameters<typeof recordException>[0];
|
||||
|
||||
expect(() => recordException(span, "string error")).not.toThrow();
|
||||
expect(() => recordException(span, 42)).not.toThrow();
|
||||
expect(() => recordException(span, null)).not.toThrow();
|
||||
expect(() => recordException(span, undefined)).not.toThrow();
|
||||
expect(() => recordException(span, { code: 500 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("wraps non-Error values in an Error before recording", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const events: unknown[] = [];
|
||||
const span = {
|
||||
recordException: (err: unknown) => {
|
||||
events.push(err);
|
||||
},
|
||||
setStatus: () => {},
|
||||
} as Parameters<typeof recordException>[0];
|
||||
|
||||
recordException(span, "plain string");
|
||||
expect(events[0]).toBeInstanceOf(Error);
|
||||
expect((events[0] as Error).message).toBe("plain string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addAttributes", () => {
|
||||
it("sets all attributes on the span", { timeout: 5000 }, () => {
|
||||
const attrs: Record<string, unknown> = {};
|
||||
const span = {
|
||||
setAttribute: (k: string, v: unknown) => {
|
||||
attrs[k] = v;
|
||||
},
|
||||
} as Parameters<typeof addAttributes>[0];
|
||||
|
||||
addAttributes(span, {
|
||||
"http.method": "GET",
|
||||
"http.status_code": 200,
|
||||
"http.url": "https://example.com",
|
||||
});
|
||||
|
||||
expect(attrs["http.method"]).toBe("GET");
|
||||
expect(attrs["http.status_code"]).toBe(200);
|
||||
expect(attrs["http.url"]).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("skips undefined values", { timeout: 5000 }, () => {
|
||||
const keys: string[] = [];
|
||||
const span = {
|
||||
setAttribute: (k: string) => {
|
||||
keys.push(k);
|
||||
},
|
||||
} as Parameters<typeof addAttributes>[0];
|
||||
|
||||
addAttributes(span, {
|
||||
"defined.key": "value",
|
||||
"undefined.key": undefined,
|
||||
});
|
||||
|
||||
expect(keys).toContain("defined.key");
|
||||
expect(keys).not.toContain("undefined.key");
|
||||
});
|
||||
|
||||
it("handles empty attributes map without throwing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const span = {
|
||||
setAttribute: () => {},
|
||||
} as Parameters<typeof addAttributes>[0];
|
||||
|
||||
expect(() => addAttributes(span, {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTraceContext", () => {
|
||||
it("returns empty object when no span is active (no-op tracer)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ctx = getTraceContext();
|
||||
expect(ctx).toEqual({});
|
||||
});
|
||||
|
||||
it("returns an object (never throws)", { timeout: 5000 }, () => {
|
||||
expect(() => getTraceContext()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSpanContextObject", () => {
|
||||
it("returns empty object for undefined span context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(buildSpanContextObject(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
it("populates traceId when present", { timeout: 5000 }, () => {
|
||||
const result = buildSpanContextObject({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: "00f067aa0ba902b7",
|
||||
traceFlags: 1,
|
||||
});
|
||||
expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
});
|
||||
|
||||
it("populates spanId when present", { timeout: 5000 }, () => {
|
||||
const result = buildSpanContextObject({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: "00f067aa0ba902b7",
|
||||
traceFlags: 1,
|
||||
});
|
||||
expect(result.spanId).toBe("00f067aa0ba902b7");
|
||||
});
|
||||
|
||||
it("populates traceFlags when present", { timeout: 5000 }, () => {
|
||||
const result = buildSpanContextObject({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: "00f067aa0ba902b7",
|
||||
traceFlags: 1,
|
||||
});
|
||||
expect(result.traceFlags).toBe(1);
|
||||
});
|
||||
|
||||
it("omits traceId when undefined in span context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = buildSpanContextObject({
|
||||
traceId: undefined as unknown as string,
|
||||
spanId: "00f067aa0ba902b7",
|
||||
traceFlags: 1,
|
||||
});
|
||||
expect("traceId" in result).toBe(false);
|
||||
expect(result.spanId).toBe("00f067aa0ba902b7");
|
||||
expect(result.traceFlags).toBe(1);
|
||||
});
|
||||
|
||||
it("omits spanId when undefined in span context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = buildSpanContextObject({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: undefined as unknown as string,
|
||||
traceFlags: 1,
|
||||
});
|
||||
expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
expect("spanId" in result).toBe(false);
|
||||
expect(result.traceFlags).toBe(1);
|
||||
});
|
||||
|
||||
it("omits traceFlags when undefined in span context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = buildSpanContextObject({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId: "00f067aa0ba902b7",
|
||||
traceFlags: undefined as unknown as number,
|
||||
});
|
||||
expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
||||
expect(result.spanId).toBe("00f067aa0ba902b7");
|
||||
expect("traceFlags" in result).toBe(false);
|
||||
});
|
||||
|
||||
it("getTraceContext accepts a span for testability", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const tracer = trace.getTracer("test");
|
||||
await withSpan(tracer, "ctx-test", async (span) => {
|
||||
// The no-op span context has traceId/spanId/traceFlags set to
|
||||
// their zero values; verify getTraceContext accepts it
|
||||
const ctx = getTraceContext(span);
|
||||
expect(typeof ctx).toBe("object");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
type Attributes,
|
||||
type Span,
|
||||
SpanStatusCode,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Execute `fn` inside a new child span, automatically ending the span in a
|
||||
* `finally` block. If `fn` throws, the exception is recorded on the span and
|
||||
* the span status is set to ERROR before the error is re-thrown.
|
||||
*
|
||||
* @param tracer - OTel `Tracer` instance used to create the span.
|
||||
* @param name - Span name shown in trace visualizers.
|
||||
* @param fn - Async callback receiving the active `Span`.
|
||||
* @param options.attributes - Optional initial attributes to set on the span.
|
||||
* @returns The resolved value of `fn`.
|
||||
* @throws Re-throws whatever `fn` threw, after recording it on the span.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = await withSpan(tracer, "db.query", async (span) => {
|
||||
* span.setAttribute("db.statement", sql);
|
||||
* return await db.execute(sql);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function withSpan<T>(
|
||||
tracer: Tracer,
|
||||
name: string,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
options?: { attributes?: Attributes }
|
||||
): Promise<T> {
|
||||
const spanOptions =
|
||||
options?.attributes !== undefined
|
||||
? { attributes: options.attributes }
|
||||
: {};
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
tracer.startActiveSpan(name, spanOptions, async (span) => {
|
||||
try {
|
||||
const result = await fn(span);
|
||||
span.end();
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
recordException(span, err);
|
||||
span.end();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an exception event on the given span and set the span status to ERROR.
|
||||
*
|
||||
* Handles both `Error` instances and arbitrary thrown values by normalising
|
||||
* non-Error values to a string representation before recording.
|
||||
*
|
||||
* @param span - The span to record the exception on.
|
||||
* @param error - Any thrown value (Error, string, number, etc.).
|
||||
*/
|
||||
export function recordException(span: Span, error: unknown): void {
|
||||
if (error instanceof Error) {
|
||||
span.recordException(error);
|
||||
} else {
|
||||
span.recordException(new Error(String(error)));
|
||||
}
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one or more attributes on a span in bulk.
|
||||
*
|
||||
* This is a thin convenience wrapper over `span.setAttribute` that accepts
|
||||
* the full `Attributes` map at once, which is useful when building attribute
|
||||
* maps programmatically before attaching them.
|
||||
*
|
||||
* @param span - The span to add attributes to.
|
||||
* @param attrs - Key/value map of attributes to set.
|
||||
*/
|
||||
export function addAttributes(span: Span, attrs: Attributes): void {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (value !== undefined) {
|
||||
span.setAttribute(
|
||||
key,
|
||||
value as Parameters<Span["setAttribute"]>[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the trace context from the currently active span.
|
||||
*
|
||||
* Returns an object with `traceId`, `spanId`, and `traceFlags` drawn from
|
||||
* the active span context. When no span is active all values are `undefined`.
|
||||
*
|
||||
* @param span - Optional span to read context from. Defaults to the currently active span.
|
||||
* @returns An object containing the current trace context fields.
|
||||
*/
|
||||
export function getTraceContext(span?: Span): {
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
traceFlags?: number;
|
||||
} {
|
||||
const spanContext = (span ?? trace.getActiveSpan())?.spanContext();
|
||||
return buildSpanContextObject(spanContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plain trace-context object from an optional span context.
|
||||
*
|
||||
* Exported for testing convenience.
|
||||
* @internal
|
||||
*/
|
||||
export function buildSpanContextObject(
|
||||
spanContext: ReturnType<Span["spanContext"]> | undefined
|
||||
): {
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
traceFlags?: number;
|
||||
} {
|
||||
if (spanContext === undefined) return {};
|
||||
const ctx: { traceId?: string; spanId?: string; traceFlags?: number } = {};
|
||||
if (spanContext.traceId !== undefined) ctx.traceId = spanContext.traceId;
|
||||
if (spanContext.spanId !== undefined) ctx.spanId = spanContext.spanId;
|
||||
if (spanContext.traceFlags !== undefined)
|
||||
ctx.traceFlags = spanContext.traceFlags;
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly error + types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(["@suaveplan/error", "@suaveplan/types"]);
|
||||
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
27
packages/observability/monitor/opentelemetry/vite.config.ts
Normal file
27
packages/observability/monitor/opentelemetry/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/opentelemetry",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user