CI / build (push) Successful in 4m26s
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.
150 lines
5.1 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
}
|