From 3f589e61b4f5ec5568fd206b93be6714cd4ab2dc Mon Sep 17 00:00:00 2001 From: Mateusz Bocian Date: Wed, 22 Oct 2025 15:13:45 -0400 Subject: [PATCH] feat: add instrumentation to @netlify/cache --- package-lock.json | 18 +++++ packages/cache/package.json | 1 + packages/cache/src/bootstrap/cache.ts | 104 +++++++++++++++++--------- packages/cache/tsconfig.json | 4 +- 4 files changed, 88 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b5c1827..2bb9205c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19299,6 +19299,7 @@ "version": "3.3.0", "license": "MIT", "dependencies": { + "@netlify/otel": "^4.3.1", "@netlify/runtime-utils": "2.2.0" }, "devDependencies": { @@ -19312,6 +19313,23 @@ "node": ">=20.6.1" } }, + "packages/cache/node_modules/@netlify/otel": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-4.3.1.tgz", + "integrity": "sha512-deFAOlU77Bw52YhUHcO9FFfikqGOfURsjwdgyJ24EP2xWO2CPgDgmwuCAZKNJeOCcS0qUA1xXyKhgb53n+hjxw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "1.9.0", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/instrumentation": "^0.203.0", + "@opentelemetry/otlp-transformer": "0.57.2", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/sdk-trace-node": "1.30.1" + }, + "engines": { + "node": "^18.14.0 || >=20.6.1" + } + }, "packages/cache/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", diff --git a/packages/cache/package.json b/packages/cache/package.json index 8be9d187..b9d9a539 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -75,6 +75,7 @@ "vitest": "^3.0.0" }, "dependencies": { + "@netlify/otel": "^4.3.1", "@netlify/runtime-utils": "2.2.0" } } diff --git a/packages/cache/src/bootstrap/cache.ts b/packages/cache/src/bootstrap/cache.ts index 15bc83b6..9bf8dfdc 100644 --- a/packages/cache/src/bootstrap/cache.ts +++ b/packages/cache/src/bootstrap/cache.ts @@ -1,3 +1,5 @@ +import { getTracer, withActiveSpan } from '@netlify/otel' +import { SpanStatusCode } from '@netlify/otel/opentelemetry' import { base64Encode } from '@netlify/runtime-utils' import { EnvironmentOptions, RequestContext, Operation, RequestContextFactory } from './environment.js' @@ -105,11 +107,18 @@ export class NetlifyCache implements Cache { const context = this.#getContext({ operation: Operation.Delete }) if (context) { - const resourceURL = extractAndValidateURL(request) - - await fetch(`${context.url}/${toCacheKey(resourceURL)}`, { - headers: this[getInternalHeaders](context), - method: 'DELETE', + await withActiveSpan(getTracer(), 'cache.delete', async (span) => { + const resourceURL = extractAndValidateURL(request) + + span?.setAttributes({ + 'cache.store': this.#name, + 'cache.key': resourceURL.toString(), + }) + + await fetch(`${context.url}/${toCacheKey(resourceURL)}`, { + headers: this[getInternalHeaders](context), + method: 'DELETE', + }) }) } @@ -129,21 +138,30 @@ export class NetlifyCache implements Cache { return } - const resourceURL = extractAndValidateURL(request) - const cacheURL = `${context.url}/${toCacheKey(resourceURL)}` - const response = await fetch(cacheURL, { - headers: { - ...(request instanceof Request ? this[serializeRequestHeaders](request.headers) : {}), - ...this[getInternalHeaders](context), - }, - method: 'GET', - }) - - if (!response.ok) { - return - } + return await withActiveSpan(getTracer(), 'cache.read', async (span) => { + const resourceURL = extractAndValidateURL(request) + const cacheURL = `${context.url}/${toCacheKey(resourceURL)}` + + span?.setAttributes({ + 'cache.store': this.#name, + 'cache.key': resourceURL.toString(), + }) + + const response = await fetch(cacheURL, { + headers: { + ...(request instanceof Request ? this[serializeRequestHeaders](request.headers) : {}), + ...this[getInternalHeaders](context), + }, + method: 'GET', + }) + + if (!response.ok) { + span?.setStatus({ code: SpanStatusCode.ERROR }) + return + } - return response + return response + }) } catch { // no-op } @@ -182,26 +200,38 @@ export class NetlifyCache implements Cache { return } - const resourceURL = extractAndValidateURL(request) - - const cacheResponse = await fetch(`${context.url}/${toCacheKey(resourceURL)}`, { - body: response.body, - headers: { - ...this[getInternalHeaders](context), - [HEADERS.ResourceHeaders]: this[serializeResponseHeaders](response.headers), - [HEADERS.ResourceStatus]: response.status.toString(), - }, - // @ts-expect-error https://github.com/whatwg/fetch/pull/1457 - duplex: 'half', - method: 'POST', - }) + await withActiveSpan(getTracer(), 'cache.write', async (span) => { + const resourceURL = extractAndValidateURL(request) - if (!cacheResponse.ok) { - const errorDetail = cacheResponse.headers?.get(HEADERS.ErrorDetail) ?? '' - const errorMessage = ERROR_CODES[errorDetail as keyof typeof ERROR_CODES] || GENERIC_ERROR + span?.setAttributes({ + 'cache.store': this.#name, + 'cache.key': resourceURL.toString(), + }) - context.logger?.(`Failed to write to the cache: ${errorMessage}`) - } + const cacheResponse = await fetch(`${context.url}/${toCacheKey(resourceURL)}`, { + body: response.body, + headers: { + ...this[getInternalHeaders](context), + [HEADERS.ResourceHeaders]: this[serializeResponseHeaders](response.headers), + [HEADERS.ResourceStatus]: response.status.toString(), + }, + // @ts-expect-error https://github.com/whatwg/fetch/pull/1457 + duplex: 'half', + method: 'POST', + }) + + if (!cacheResponse.ok) { + const errorDetail = cacheResponse.headers?.get(HEADERS.ErrorDetail) ?? '' + const errorMessage = ERROR_CODES[errorDetail as keyof typeof ERROR_CODES] || GENERIC_ERROR + + span?.setStatus({ + code: SpanStatusCode.ERROR, + message: errorMessage, + }) + + context.logger?.(`Failed to write to the cache: ${errorMessage}`) + } + }) } } diff --git a/packages/cache/tsconfig.json b/packages/cache/tsconfig.json index 7fedf6ee..9431fa84 100644 --- a/packages/cache/tsconfig.json +++ b/packages/cache/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