UNPKG

@fiberplane/hono-otel

Version:

Hono middleware to forward OpenTelemetry traces to a local instance of @fiberplane/studio

153 lines (152 loc) 8.39 kB
import { SpanKind, context } from "@opentelemetry/api"; import { Resource } from "@opentelemetry/resources"; import { BasicTracerProvider, // BatchSpanProcessor, SimpleSpanProcessor, } from "@opentelemetry/sdk-trace-base"; import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; // TODO figure out if we can use something else import { AsyncLocalStorageContextManager } from "./async-hooks/index.js"; import { resolveConfig, setFpResolvedConfig, } from "./config/index.js"; import { FPOTLPExporter } from "./exporter.js"; import { getLogger } from "./logger.js"; import { measure } from "./measure.js"; import { patchCloudflareBindings, patchConsole, patchFetch, patchWaitUntil, } from "./patch/index.js"; import { PromiseStore } from "./promiseStore.js"; import { propagateFpxTraceId } from "./propagation.js"; import { cloneRequestForAttributes, getIncomingRequestAttributes, getRequestAttributes, getResponseAttributes, } from "./utils/index.js"; // Freeze the web standard fetch function so that we can use it without creating new spans // In the future, we could allow the user to set their own custom "fetchFn" const webStandardFetch = fetch; export function instrument(app, userConfig) { return new Proxy(app, { // Intercept the `fetch` function on the Hono app instance get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (prop === "fetch" && typeof value === "function") { const originalFetch = value; return async function fetch(request, // Named this "rawEnv" because we coerce it below rawEnv, executionContext) { // Coerce the rawEnv to an FpHonoEnv, since it's easier to work with const env = rawEnv; const resolvedConfig = resolveConfig(userConfig, env); const { otelEndpoint, otelToken, serviceName, monitor: { fetch: monitorFetch, logging: monitorLogging, cfBindings: monitorCfBindings, }, } = resolvedConfig; const logger = getLogger(resolvedConfig.logLevel); // NOTE - Only prints if debug mode is enabled logger.debug("Library debug logging is enabled"); logger.debug(`Library mode: ${resolvedConfig.mode}`); if (!resolvedConfig.enabled || !otelEndpoint) { logger.debug("Missing FIBERPLANE_OTEL_ENDPOINT. Skipping instrumentation"); return await originalFetch(request, rawEnv, executionContext); } // Ignore instrumentation for requests that have the x-fpx-ignore header // This is a useful escape hatch, please keep that in mind if you're tempted to remove it. if (request.headers.get("x-fpx-ignore")) { logger.debug("Ignoring request due to x-fpx-ignore header: ", request.url?.toString?.()); return await originalFetch(request, rawEnv, executionContext); } // Patch all functions we want to monitor in the runtime if (monitorCfBindings) { patchCloudflareBindings(env); } if (monitorLogging) { patchConsole(); } if (monitorFetch) { patchFetch(); } const provider = setupTracerProvider({ serviceName, otelEndpoint, otelToken, fetchFn: webStandardFetch, logger, }); const promiseStore = new PromiseStore(); // Enable tracing for waitUntil (Cloudflare only - allows us to still trace promises that extend beyond the life of the request itself) const proxyExecutionCtx = executionContext && patchWaitUntil(executionContext, promiseStore); // Create the context for the request // - Propagate the trace ID // - Set the resolved config let activeContext = propagateFpxTraceId(request); activeContext = setFpResolvedConfig(activeContext, resolvedConfig); const { requestForAttributes, newRequest } = cloneRequestForAttributes(request, resolvedConfig); // Parse the headers for the root request. // In "local" mode, this will also parse the body, which does add some latency. // NOTE - We invoke this outside of the measure call, so that we can use the cloned request body for attributes in "local" mode const rootRequestAttributes = await getIncomingRequestAttributes(requestForAttributes, env, resolvedConfig); const measuredFetch = measure({ name: "request", spanKind: SpanKind.SERVER, onStart: (span, [request]) => { const requestAttributes = { ...getRequestAttributes(request, undefined, resolvedConfig), ...rootRequestAttributes, }; span.setAttributes(requestAttributes); }, endSpanManually: true, onSuccess: async (span, response) => { span.addEvent("first-response"); const updateSpan = async (response) => { const attributes = await getResponseAttributes(response, resolvedConfig); span.setAttributes(attributes); span.end(); }; promiseStore.add(updateSpan(response)); }, checkResult: async (result) => { const r = await result; if (r.status >= 500) { throw new Error(r.statusText); } }, logger, }, originalFetch); try { return await context.with(activeContext, () => measuredFetch(newRequest, rawEnv, proxyExecutionCtx)); } finally { // Make sure all promises are resolved before sending data to the server if (proxyExecutionCtx) { proxyExecutionCtx.waitUntil(promiseStore.allSettled().finally(() => { return provider.forceFlush(); })); } else { // Otherwise just await flushing the provider await provider.forceFlush(); } } }; } // Keep all the other things accessible return value; }, }); } function setupTracerProvider(options) { const { otelEndpoint, otelToken, serviceName, fetchFn, logger } = options; // We need to use async hooks to be able to propagate context const asyncHooksContextManager = new AsyncLocalStorageContextManager(); asyncHooksContextManager.enable(); context.setGlobalContextManager(asyncHooksContextManager); const provider = new BasicTracerProvider({ resource: new Resource({ [SEMRESATTRS_SERVICE_NAME]: serviceName, }), }); const headers = otelToken ? { Authorization: `Bearer ${otelToken}` } : {}; const exporter = new FPOTLPExporter({ url: otelEndpoint, headers, }, // NOTE - We bind the fetch function to globalThis so that it can be called // without a specific context. Otherwise, I got a runtime error. fetchFn.bind(globalThis), logger); provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); provider.register(); return provider; }