Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s
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.
This commit is contained in:
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user