Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/observability/smoke-otel.ts
T

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();