Files
verse-quote-app/server/otel.ts
T
exe.dev user 0a18c774a5
CI / build (push) Successful in 4m26s
Build Verse: a poem-quote app on the agent-native chat template
Adds a Postgres-backed quotes domain (poems/quotes/favorites) exposed as
five agent-native actions (list-quotes, get-random-quote, create-quote,
toggle-favorite, list-favorites) callable from both chat and the React UI
via useActionQuery/useActionMutation. Moves the primary UI from chat to a
mobile-first Quotes/Favorites experience (chat moves to /chat), adds
manual OpenTelemetry instrumentation exporting traces/metrics/logs over
OTLP, and wires a local Docker Postgres for shared state.
2026-07-25 17:42:04 +00:00

150 lines
5.1 KiB
TypeScript

/**
* OpenTelemetry setup for verse-quote-app: exports traces, metrics, and logs
* over OTLP/HTTP to the shared Grafana LGTM collector on this VM
* (http://localhost:4318). Manual instrumentation rather than
* auto-instrumentation-node — this app's ESM/Nitro entrypoint doesn't give
* us a require-before-everything-else hook to patch http/pg at, so we
* instrument the one seam that matters for this app: action calls.
*/
import { trace, metrics, diag, DiagConsoleLogger, DiagLogLevel, type Span } from "@opentelemetry/api";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import {
BasicTracerProvider,
BatchSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import {
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
import {
LoggerProvider,
BatchLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || "verse-quote-app";
const OTLP_ENDPOINT =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318";
let started = false;
export function startOtel(): void {
if (started) return;
started = true;
if (process.env.OTEL_DEBUG) {
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
}
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: SERVICE_NAME,
});
const tracerProvider = new BasicTracerProvider({
resource,
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }),
),
],
});
trace.setGlobalTracerProvider(tracerProvider);
const meterProvider = new MeterProvider({
resource,
readers: [
new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({ url: `${OTLP_ENDPOINT}/v1/metrics` }),
exportIntervalMillis: 15000,
}),
],
});
metrics.setGlobalMeterProvider(meterProvider);
const loggerProvider = new LoggerProvider({
resource,
processors: [
new BatchLogRecordProcessor({
exporter: new OTLPLogExporter({ url: `${OTLP_ENDPOINT}/v1/logs` }),
}),
],
});
logs.setGlobalLoggerProvider(loggerProvider);
const shutdown = () => {
Promise.allSettled([
tracerProvider.shutdown(),
meterProvider.shutdown(),
loggerProvider.shutdown(),
]).finally(() => process.exit(0));
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
console.log(`[otel] started, exporting to ${OTLP_ENDPOINT} as service "${SERVICE_NAME}"`);
}
// Must run before getMeter()/createCounter() below: unlike the tracer and
// logger proxies (which re-resolve their delegate on every call), the metrics
// API's Counter is bound to whatever provider is active at createCounter()
// time — calling startOtel() after creating counters would permanently bind
// them to the no-op backend and silently drop every metric.
startOtel();
const tracer = trace.getTracer(SERVICE_NAME);
const meter = metrics.getMeter(SERVICE_NAME);
const otelLogger = logs.getLogger(SERVICE_NAME);
export const actionCallCounter = meter.createCounter("verse_quote_action_calls_total", {
description: "Number of verse-quote-app action invocations, by action name and outcome",
});
export const quoteFavoriteCounter = meter.createCounter("verse_quote_favorites_total", {
description: "Number of favorite/unfavorite toggles on quotes",
});
/**
* Wrap an action's run() body in a span + call counter + structured log.
* Records success/error outcome and re-throws so defineAction's own error
* handling is unaffected.
*/
export async function withActionSpan<T>(
actionName: string,
attributes: Record<string, string | number | boolean | undefined>,
fn: (span: Span) => Promise<T>,
): Promise<T> {
return tracer.startActiveSpan(`action.${actionName}`, async (span) => {
for (const [key, value] of Object.entries(attributes)) {
if (value !== undefined) span.setAttribute(key, value);
}
const start = Date.now();
try {
const result = await fn(span);
actionCallCounter.add(1, { action: actionName, outcome: "success" });
otelLogger.emit({
severityNumber: SeverityNumber.INFO,
severityText: "INFO",
body: `action ${actionName} succeeded in ${Date.now() - start}ms`,
attributes: { action: actionName, ...attributes },
});
return result;
} catch (err) {
actionCallCounter.add(1, { action: actionName, outcome: "error" });
span.recordException(err instanceof Error ? err : new Error(String(err)));
otelLogger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: "ERROR",
body: `action ${actionName} failed: ${err instanceof Error ? err.message : String(err)}`,
attributes: { action: actionName, ...attributes },
});
throw err;
} finally {
span.end();
}
});
}