diff --git a/package-lock.json b/package-lock.json index 413625a5..26b020aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10951,6 +10951,7 @@ "license": "MIT", "dependencies": { "@netlify/dev-utils": "3.2.2", + "@netlify/otel": "^3.0.2", "@netlify/runtime-utils": "2.1.0" }, "devDependencies": { @@ -11053,6 +11054,7 @@ "version": "3.0.5", "license": "MIT", "dependencies": { + "@netlify/otel": "^3.0.2", "@netlify/runtime-utils": "2.1.0" }, "devDependencies": { @@ -11261,6 +11263,7 @@ "dependencies": { "@netlify/blobs": "10.0.4", "@netlify/dev-utils": "3.2.2", + "@netlify/otel": "^3.0.2", "@netlify/serverless-functions-api": "2.1.3", "@netlify/zip-it-and-ship-it": "^12.2.0", "cron-parser": "^4.9.0", diff --git a/packages/blobs/package.json b/packages/blobs/package.json index c83a9fc0..91b28eff 100644 --- a/packages/blobs/package.json +++ b/packages/blobs/package.json @@ -77,6 +77,7 @@ }, "dependencies": { "@netlify/dev-utils": "3.2.2", + "@netlify/otel": "^3.0.2", "@netlify/runtime-utils": "2.1.0" } } diff --git a/packages/blobs/src/store.ts b/packages/blobs/src/store.ts index b97d5fbe..e79cd91b 100644 --- a/packages/blobs/src/store.ts +++ b/packages/blobs/src/store.ts @@ -1,3 +1,4 @@ +import { getTracer } from '@netlify/otel' import { ListResponse, ListResponseBlob } from './backend/list.ts' import { Client, type Conditions } from './client.ts' import type { ConsistencyMode } from './consistency.ts' @@ -5,6 +6,8 @@ import { getMetadataFromResponse, Metadata } from './metadata.ts' import { BlobInput, HTTPMethod } from './types.ts' import { BlobsInternalError, collectIterator } from './util.ts' +import { Attributes, Span, SpanStatusCode } from '@opentelemetry/api' + export const DEPLOY_STORE_PREFIX = 'deploy:' export const LEGACY_STORE_INTERNAL_PREFIX = 'netlify-internal/legacy-namespace/' export const SITE_STORE_PREFIX = 'site:' @@ -105,11 +108,51 @@ export type WriteResult = { modified: boolean } +function otel(getAttributes?: (...args: Args) => Attributes) { + return function (method: (...args: Args) => Promise, context: ClassMethodDecoratorContext) { + const methodName = String(context.name) + const operationName = `blobs.${methodName}` + return async function (this: This, ...args: Args): Promise { + const storeName = this['name'] + const tracer = await getTracer() + + if (tracer) { + return tracer.withActiveSpan(operationName, async (span) => { + span.setAttribute('blobs.store', storeName) + if (getAttributes) { + for (const [name, value] of Object.entries(getAttributes(...args))) { + value && span.setAttribute(`blobs.${name}`, value) + } + } + try { + this['span'] = span + const result = await method.apply(this, args) + span.setStatus({ code: SpanStatusCode.OK }) + return result + } catch (error) { + const message = (error as Error).message ?? 'problem' + span.setStatus({ + code: SpanStatusCode.ERROR, + message, + }) + span.recordException(error as Error) + throw error + } finally { + this['span'] = null + } + }) + } + return await method.apply(this, args) + } + } +} + export type BlobResponseType = 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'text' export class Store { private client: Client private name: string + private span: Span | null = null constructor(options: StoreOptions) { this.client = options.client @@ -153,6 +196,11 @@ export class Store { async get(key: string, { type }: GetOptions & { type: 'json' }): Promise async get(key: string, { type }: GetOptions & { type: 'stream' }): Promise async get(key: string, { type }: GetOptions & { type: 'text' }): Promise + @otel((key, options) => ({ + method: 'GET', + key, + response_type: options?.type ?? 'text', + })) async get( key: string, options?: GetOptions & { type?: BlobResponseType }, @@ -191,6 +239,11 @@ export class Store { throw new BlobsInternalError(res) } + @otel((key, options) => ({ + method: 'GET', + key, + consistency: options?.consistency, + })) async getMetadata(key: string, { consistency }: { consistency?: ConsistencyMode } = {}) { const res = await this.client.makeRequest({ consistency, key, method: HTTPMethod.HEAD, storeName: this.name }) @@ -242,6 +295,12 @@ export class Store { options: { type: 'text' } & GetWithMetadataOptions, ): Promise<({ data: string } & GetWithMetadataResult) | null> + @otel((key, options) => ({ + method: 'GET', + key, + consistency: options?.consistency, + response_type: options?.type ?? 'text', + })) async getWithMetadata( key: string, options?: { type: BlobResponseType } & GetWithMetadataOptions, @@ -325,6 +384,12 @@ export class Store { ) } + @otel((key, data, options) => ({ + method: 'PUT', + key, + atomic: options?.onlyIfMatch || options?.onlyIfNew, + type: typeof data == 'string' ? 'text' : data instanceof Blob ? data.type : 'arrayBuffer', + })) async set(key: string, data: BlobInput, options: SetOptions = {}): Promise { Store.validateKey(key) @@ -353,6 +418,12 @@ export class Store { throw new BlobsInternalError(res) } + @otel((key, data, options) => ({ + method: 'PUT', + key, + atomic: options?.onlyIfMatch || options?.onlyIfNew, + type: 'json', + })) async setJSON(key: string, data: unknown, options: SetOptions = {}): Promise { Store.validateKey(key) diff --git a/packages/blobs/tsconfig.json b/packages/blobs/tsconfig.json index 7fedf6ee..a934e658 100644 --- a/packages/blobs/tsconfig.json +++ b/packages/blobs/tsconfig.json @@ -3,7 +3,7 @@ "allowImportingTsExtensions": true, "emitDeclarationOnly": true, "target": "ES2020", - "module": "es2020", + "module": "NodeNext", "allowJs": true, "declaration": true, "declarationMap": false, @@ -11,7 +11,7 @@ "outDir": "./dist", "removeComments": false, "strict": true, - "moduleResolution": "node", + "moduleResolution": "NodeNext", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true diff --git a/packages/cache/package.json b/packages/cache/package.json index 9a308a74..aca51645 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -75,6 +75,7 @@ "vitest": "^3.0.0" }, "dependencies": { + "@netlify/otel": "^3.0.2", "@netlify/runtime-utils": "2.1.0" } } diff --git a/packages/functions/package.json b/packages/functions/package.json index 518363f9..83b52f8f 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -82,6 +82,7 @@ "dependencies": { "@netlify/blobs": "10.0.4", "@netlify/dev-utils": "3.2.2", + "@netlify/otel": "^3.0.2", "@netlify/serverless-functions-api": "2.1.3", "@netlify/zip-it-and-ship-it": "^12.2.0", "cron-parser": "^4.9.0", diff --git a/packages/otel/src/bootstrap/main.ts b/packages/otel/src/bootstrap/main.ts index 8b533361..50deee61 100644 --- a/packages/otel/src/bootstrap/main.ts +++ b/packages/otel/src/bootstrap/main.ts @@ -17,7 +17,7 @@ export const createTracerProvider = async (options: { const runtimeVersion = nodeVersion.slice(1) const { Resource } = await import('@opentelemetry/resources') - const { NodeTracerProvider, BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node') + const { NodeTracerProvider, SimpleSpanProcessor } = await import('@opentelemetry/sdk-trace-node') const { NetlifySpanExporter } = await import('./netlify_span_exporter.js') @@ -34,7 +34,7 @@ export const createTracerProvider = async (options: { const nodeTracerProvider = new NodeTracerProvider({ resource, - spanProcessors: [new BatchSpanProcessor(new NetlifySpanExporter())], + spanProcessors: [new SimpleSpanProcessor(new NetlifySpanExporter())], }) nodeTracerProvider.register()