UNPKG

@fiberplane/hono-otel

Version:

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

385 lines (384 loc) 14.6 kB
import { getShouldTraceEverything } from "../config/index.js"; import { CF_BINDING_ERROR, CF_BINDING_METHOD, CF_BINDING_NAME, CF_BINDING_RESULT, CF_BINDING_TYPE, } from "../constants.js"; import { measure } from "../measure.js"; import { errorToJson, isObject, isUintArray, objectWithKey, safelySerializeJSON, } from "../utils/index.js"; /** * A key used to mark objects as proxied by us, so that we don't proxy them again. * * @internal */ const IS_PROXIED_KEY = "__fpx_proxied"; /** * Patch Cloudflare bindings to add instrumentation * * @param env - The environment for the worker, which may contain Cloudflare bindings */ export function patchCloudflareBindings(env) { const envKeys = env ? Object.keys(env) : []; for (const bindingName of envKeys) { // Skip any environment variables that are not objects, // since they can't be bindings const envValue = env?.[bindingName]; if (!envValue || typeof envValue !== "object") { continue; } env[bindingName] = patchCloudflareBinding(envValue, bindingName); } } /** * Proxy a Cloudflare binding to add instrumentation. * For now, just wraps all functions on the binding to use a measured version of the function. * * For R2, we could still specifically proxy and add smarts for: * - createMultipartUpload * - resumeMultipartUpload * * @param o - The binding to proxy * @param bindingName - The name of the binding in the environment, e.g., "AI" or "AVATARS_BUCKET" * * @returns A proxied binding */ function patchCloudflareBinding(o, bindingName) { // HACK - Check this first, since Worker bindings are a special case // where any property access is interpreted as an RPC call to the worker. if (isCloudflareWorkerBinding(o)) { return proxyServiceBinding(o, bindingName); } if (!isCloudflareBinding(o)) { return o; } if (isAlreadyProxied(o)) { return o; } // HACK - Special logic for D1, since we only really care about the `_send` and `_sendOrThrow` methods, // not about the `prepare`, etc, methods. if (isCloudflareD1Binding(o)) { return proxyD1Binding(o, bindingName); } const proxiedBinding = new Proxy(o, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value === "function") { const methodName = String(prop); // OPTIMIZE - Do we want to do these lookups / this wrapping every time the property is accessed? const bindingType = getConstructorName(target); // The name for the span, which will show up in the UI const name = `${bindingName}.${methodName}`; const measuredBinding = measure({ name, attributes: getCfBindingAttributes(bindingType, bindingName, methodName), onStart: (span, args) => { span.setAttributes({ args: safelySerializeJSON(args), }); }, onSuccess: (span, result) => { addResultAttribute(span, result); }, onError: handleError, }, // OPTIMIZE - bind is expensive, can we avoid it? value.bind(target)); return measuredBinding; } return value; }, }); // We need to mark the binding as proxied so that we don't proxy it again in the future, // since Workers can re-use env vars across requests. markAsProxied(proxiedBinding); return proxiedBinding; } /** * Proxy a Service binding to add instrumentation to its RPC calls * * @param o - The Service binding to proxy * * @returns A proxied binding for a bound Worker (see: https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) */ function proxyServiceBinding(o, bindingName) { if (!isCloudflareWorkerBinding(o)) { return o; } if (isAlreadyProxied(o)) { return o; } const proxiedBinding = new Proxy(o, { get(serviceTarget, serviceProp) { const serviceMethod = String(serviceProp); const serviceValue = Reflect.get(serviceTarget, serviceProp); // NOTE - Should ignore some common methods and properties on the binding if (serviceMethod === "toJSON" || serviceMethod === "connect" || serviceMethod === "constructor") { return serviceValue; } if (typeof serviceValue === "function") { const bindingType = "Worker"; // The name for the span, which will show up in the UI const name = `${bindingName}.${serviceMethod}`; // For the `fetch` method, we need to bind `this` (the javascript `this` keyword) to the service target, // otherwise `fetch` will fail! // For RPC calls we do not need to do any special `this` binding const shouldBindThis = serviceMethod === "fetch"; const measuredBinding = measure({ name, attributes: getCfBindingAttributes(bindingType, bindingName, serviceMethod), onStart: (span, args) => { if (getShouldTraceEverything()) { span.setAttributes({ args: safelySerializeJSON(args), }); } }, // NOTE - Must be async, since the `result` is a custom thenable from Cloudflare onSuccess: async (span, result) => { addResultAttribute(span, result); return result; }, onError: handleError, }, shouldBindThis ? serviceValue.bind(serviceTarget) : serviceValue); return measuredBinding; } return serviceValue; }, }); markAsProxied(proxiedBinding); return proxiedBinding; } /** * Proxy a D1 binding to add instrumentation to database calls. * * In order to instrument the calls to the database itself, we need to proxy the `_send` and `_sendOrThrow` methods. * As of writing, the code that makes these calls is here: * https://github.com/cloudflare/workerd/blob/bee639d6c2ff41bfc1bd75a40c9d3c98724585ce/src/cloudflare/internal/d1-api.ts#L131 * * @param o - The D1Database binding to proxy * * @returns A proxied binding, whose `.database._send` and `.database._sendOrThrow` methods are instrumented */ function proxyD1Binding(o, bindingName) { if (!isCloudflareD1Binding(o)) { return o; } if (isAlreadyProxied(o)) { return o; } // This is how we monitor db calls in versions of miniflare after: https://github.com/cloudflare/workerd/commit/11661908ea8b6825b6d91703717f12daa6688db9 if (hasCloudflareD1Session(o)) { if (!isAlreadyProxied(o.alwaysPrimarySession)) { const proxiedPrimarySession = new Proxy(o.alwaysPrimarySession, { get(primarySessionTarget, primarySessionProp) { return measureD1Queries(bindingName, primarySessionTarget, primarySessionProp); }, }); markAsProxied(proxiedPrimarySession); o.alwaysPrimarySession = proxiedPrimarySession; } } // This is how we monitor db calls in versions of miniflare before https://github.com/cloudflare/workerd/commit/11661908ea8b6825b6d91703717f12daa6688db9 const d1Proxy = new Proxy(o, { get(d1Target, d1Prop) { return measureD1Queries(bindingName, d1Target, d1Prop); }, }); markAsProxied(d1Proxy); return d1Proxy; } function measureD1Queries(bindingName, d1Target, d1Prop) { const d1Method = String(d1Prop); const d1Value = Reflect.get(d1Target, d1Prop); // HACK - These are technically public methods on the database object, // but they have an underscore prefix which usually means "private" by convention. // const isSendingQuery = d1Method === "_send" || d1Method === "_sendOrThrow"; if (typeof d1Value === "function" && isSendingQuery) { return measure({ name: "D1 Query", attributes: getCfBindingAttributes("D1Database", bindingName, d1Method), onStart: (span, args) => { if (getShouldTraceEverything()) { span.setAttributes({ args: safelySerializeJSON(args), }); } }, onSuccess: (span, result) => { addResultAttribute(span, result); }, onError: handleError, }, // OPTIMIZE - bind is expensive, can we avoid it? d1Value.bind(d1Target)); } return d1Value; } /** * Get the attributes for a Cloudflare binding * * @param bindingType - The type of the binding, e.g., "D1Database" or "R2Bucket" * @param bindingName - The name of the binding in the environment, e.g., "AI" or "AVATARS_BUCKET" * @param methodName - The name of the method being called on the binding, e.g., "run" or "put" * * @returns The attributes for the binding */ function getCfBindingAttributes(bindingType, bindingName, methodName) { return { [CF_BINDING_TYPE]: bindingType, [CF_BINDING_NAME]: bindingName, [CF_BINDING_METHOD]: methodName, }; } /** * Add "cf.binding.result" attribute to a span * * @NOTE - The results of method calls could be so wildly different, and sometimes very large. * We should be more careful here with what we attribute to the span. * Also, might want to turn this off by default in production. * * @param span - The span to add the attribute to * @param result - The result to add to the span */ function addResultAttribute(span, result) { if (getShouldTraceEverything()) { // HACK - Probably a smarter way to avoid serlializing massive amounts of binary data, but this works for now const isBinary = isUintArray(result); span.setAttributes({ [CF_BINDING_RESULT]: isBinary ? "binary" : safelySerializeJSON(result), }); } } /** * Add "cf.binding.error" attribute to a span * * @param span - The span to add the attribute to * @param error - The error to add to the span */ function handleError(span, error) { const serializableError = error instanceof Error ? errorToJson(error) : error; const errorAttributes = { [CF_BINDING_ERROR]: safelySerializeJSON(serializableError), }; span.setAttributes(errorAttributes); } // TODO - Remove this, it is temporary function isCloudflareBinding(o) { return (isCloudflareWorkerBinding(o) || isCloudflareAiBinding(o) || isCloudflareR2Binding(o) || isCloudflareD1Binding(o) || isCloudflareKVBinding(o)); } function isCloudflareAiBinding(o) { const constructorName = getConstructorName(o); if (constructorName !== "Ai") { return false; } // TODO - Edge case, also check for `fetcher` and other known properties on this binding, in case the user is using another class named Ai (?) return true; } function isCloudflareR2Binding(o) { const constructorName = getConstructorName(o); if (constructorName !== "R2Bucket") { return false; } // TODO - Edge case, also check for `list`, `delete`, and other known methods on this binding, in case the user is using another class named R2Bucket (?) return true; } function isCloudflareD1Binding(o) { const constructorName = getConstructorName(o); if (constructorName !== "D1Database") { return false; } return true; } /** * Check if an object is a Cloudflare Worker binding * * This uses some heuristics to check for a Worker binding, since `instanceof WorkerEntrypoint` does not work. * * @param o - The object to check * @returns `true` if the object is a Cloudflare Worker binding, `false` otherwise */ function isCloudflareWorkerBinding(o) { const isFetcher = getConstructorName(o) === "Fetcher"; return (isFetcher && hasFunctionWithName(o, "fetch") && hasFunctionWithName(o, "connect")); } function hasCloudflareD1Session(o) { return (objectWithKey(o, "alwaysPrimarySession") && isObject(o.alwaysPrimarySession)); } function isCloudflareKVBinding(o) { const constructorName = getConstructorName(o); if (constructorName !== "KvNamespace") { return false; } return true; } /** * Get the constructor name of an object * * Helps us detect Cloudflare bindings * * @param o - The object to get the constructor name of * @returns The constructor name * * Example: * ```ts * const o = new Ai(); * getConstructorName(o); // "Ai" * ``` */ function getConstructorName(o) { return Object.getPrototypeOf(o)?.constructor?.name; } /** * Check if an object has a function property with a given name * * @param o - The object to check * @param name - The name of the function to check for * @returns `true` if the object has a function with the given name, `false` otherwise */ function hasFunctionWithName(o, name) { const result = o && typeof o === "object" && name in o && typeof o[name] === "function"; return !!result; } /** * Check if a Cloudflare binding is already proxied by us * * @param o - The binding to check * @returns `true` if the binding is already proxied, `false` otherwise */ function isAlreadyProxied(o) { // Any property access on a worker binding will be true, // since the property access is interpreted as an RPC call to the worker. // So, we need to check if there's a property descriptor on the object that we set. // if (isCloudflareWorkerBinding(o)) { const descriptor = getProxiedKey(o); return !!descriptor; } if (IS_PROXIED_KEY in o) { return !!o[IS_PROXIED_KEY]; } return false; } /** * Mark a Cloudflare binding as proxied by us, so that we don't proxy it again * * @param o - The binding to mark */ function markAsProxied(o) { Object.defineProperty(o, IS_PROXIED_KEY, { value: true, writable: true, configurable: true, }); } function getProxiedKey(o) { return Object.getOwnPropertyDescriptor(o, IS_PROXIED_KEY); }