162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* OpenTelemetry OTLP HTTP smoke test.
|
|
*
|
|
* Sends a synthetic trace (one parent + two children) directly to the
|
|
* configured OTLP HTTP endpoint via `fetch` (no SDK), so an operator can
|
|
* verify the end-to-end pipeline without spinning up the full api/worker
|
|
* stack:
|
|
* - DNS + TLS to the OTLP endpoint
|
|
* - Gateway / Relay routes `POST /v1/traces` (or `/<path>/v1/traces`)
|
|
* - Sentry/Tempo/Jaeger persists the trace
|
|
*
|
|
* Run from the repo root:
|
|
* OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com/v1/traces \
|
|
* pnpm exec tsx scripts/observability/smoke-otel.ts
|
|
*
|
|
* Exits 0 on 2xx response, 1 on non-2xx, 2 on missing endpoint config.
|
|
*
|
|
* @see docs/improvement-plan/02-observability/opentelemetry.md (Step 10)
|
|
*/
|
|
import { randomBytes } from "node:crypto";
|
|
|
|
/* eslint-disable no-console */
|
|
|
|
interface SmokeSpan {
|
|
traceId: string;
|
|
spanId: string;
|
|
parentSpanId: string | undefined;
|
|
name: string;
|
|
kind: number;
|
|
startTimeUnixNano: string;
|
|
endTimeUnixNano: string;
|
|
attributes: Array<{ key: string; value: { stringValue?: string; intValue?: string } }>;
|
|
}
|
|
|
|
function hex(bytes: number): string {
|
|
return randomBytes(bytes).toString("hex");
|
|
}
|
|
|
|
function buildSpans(): SmokeSpan[] {
|
|
const traceId = hex(16);
|
|
const parentSpanId = hex(8);
|
|
const apiSpanId = hex(8);
|
|
const workerSpanId = hex(8);
|
|
|
|
const start = Date.now() - 1000;
|
|
return [
|
|
{
|
|
traceId,
|
|
spanId: parentSpanId,
|
|
parentSpanId: undefined,
|
|
name: "smoke-otel.root",
|
|
kind: 1, // INTERNAL
|
|
startTimeUnixNano: String(BigInt(start) * 1_000_000n),
|
|
endTimeUnixNano: String(BigInt(start + 800) * 1_000_000n),
|
|
attributes: [
|
|
{ key: "service.name", value: { stringValue: "boilerplate-smoke" } },
|
|
{ key: "smoke.kind", value: { stringValue: "root" } },
|
|
],
|
|
},
|
|
{
|
|
traceId,
|
|
spanId: apiSpanId,
|
|
parentSpanId,
|
|
name: "smoke-otel.api",
|
|
kind: 2, // SERVER
|
|
startTimeUnixNano: String(BigInt(start + 100) * 1_000_000n),
|
|
endTimeUnixNano: String(BigInt(start + 500) * 1_000_000n),
|
|
attributes: [
|
|
{ key: "service.name", value: { stringValue: "boilerplate-smoke-api" } },
|
|
{ key: "http.method", value: { stringValue: "GET" } },
|
|
{ key: "http.target", value: { stringValue: "/healthz" } },
|
|
],
|
|
},
|
|
{
|
|
traceId,
|
|
spanId: workerSpanId,
|
|
parentSpanId: apiSpanId,
|
|
name: "smoke-otel.worker",
|
|
kind: 5, // CONSUMER
|
|
startTimeUnixNano: String(BigInt(start + 250) * 1_000_000n),
|
|
endTimeUnixNano: String(BigInt(start + 600) * 1_000_000n),
|
|
attributes: [
|
|
{ key: "service.name", value: { stringValue: "boilerplate-smoke-worker" } },
|
|
{ key: "messaging.system", value: { stringValue: "bullmq" } },
|
|
{ key: "messaging.destination", value: { stringValue: "smoke.queue" } },
|
|
],
|
|
},
|
|
];
|
|
}
|
|
|
|
function parseHeaders(raw: string | undefined): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
if (!raw) return out;
|
|
for (const pair of raw.split(",")) {
|
|
const eq = pair.indexOf("=");
|
|
if (eq < 0) continue;
|
|
const key = pair.slice(0, eq).trim();
|
|
const value = pair.slice(eq + 1).trim();
|
|
if (key) out[key] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const rawEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
if (!rawEndpoint) {
|
|
console.error("[smoke-otel] OTEL_EXPORTER_OTLP_ENDPOINT is not set; nothing to do.");
|
|
process.exit(2);
|
|
}
|
|
// Normalise: callers commonly pass the base endpoint without the `/v1/traces`
|
|
// path because the SDK appends it automatically. Replicate that here.
|
|
let url = rawEndpoint;
|
|
if (!/\/v1\/traces\/?$/.test(url)) {
|
|
url = url.replace(/\/$/, "") + "/v1/traces";
|
|
}
|
|
|
|
const spans = buildSpans();
|
|
const traceId = spans[0]?.traceId ?? "(none)";
|
|
console.log(`[smoke-otel] POST ${url} — trace_id=${traceId}, spans=${spans.length}`);
|
|
|
|
const payload = {
|
|
resourceSpans: [
|
|
{
|
|
resource: {
|
|
attributes: [
|
|
{ key: "service.name", value: { stringValue: "boilerplate-smoke" } },
|
|
{ key: "service.namespace", value: { stringValue: "toolctrl" } },
|
|
],
|
|
},
|
|
scopeSpans: [
|
|
{
|
|
scope: { name: "scripts/observability/smoke-otel.ts" },
|
|
spans,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const headers: Record<string, string> = { "content-type": "application/json", ...parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS) };
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
} catch (cause) {
|
|
console.error(`[smoke-otel] fetch failed: ${(cause as Error)?.message ?? String(cause)}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
console.error(`[smoke-otel] non-2xx response: ${response.status} ${response.statusText}\n${text.slice(0, 800)}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`[smoke-otel] ok: ${response.status}; trace_id=${traceId}`);
|
|
console.log(`[smoke-otel] body: ${text.slice(0, 400)}`);
|
|
}
|
|
|
|
void main();
|