diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml index 0b918843..c9f40aa4 100644 --- a/.github/workflows/release-please.yaml +++ b/.github/workflows/release-please.yaml @@ -54,6 +54,10 @@ jobs: if: ${{ steps.release.outputs['packages/runtime-utils--release_created'] }} env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - run: npm publish packages/otel/ --provenance --access=public + if: ${{ steps.release.outputs['packages/otel--release_created'] }} + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - run: npm publish packages/blobs/ --provenance --access=public if: ${{ steps.release.outputs['packages/blobs--release_created'] }} env: @@ -102,7 +106,3 @@ jobs: if: ${{ steps.release.outputs['packages/vite-plugin--release_created'] }} env: NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} - - run: npm publish packages/otel/ --provenance --access=public - if: ${{ steps.release.outputs['packages/otel--release_created'] }} - env: - NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 2a0328c3..87182349 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -82,9 +82,9 @@ jobs: - name: Build # NOTE: These are run in the specified order, so they must be in topological order run: >- - npm run build -w ./packages/types -w ./packages/dev-utils -w ./packages/runtime-utils -w ./packages/blobs -w - ./packages/edge-functions -w ./packages/functions + npm run build -w ./packages/types -w ./packages/dev-utils -w ./packages/runtime-utils -w ./packages/otel -w + ./packages/blobs -w ./packages/edge-functions -w ./packages/functions - name: Tests run: >- - npm run test -w ./packages/types -w ./packages/dev-utils -w ./packages/runtime-utils -w ./packages/blobs -w - ./packages/edge-functions -w ./packages/functions + npm run test -w ./packages/types -w ./packages/dev-utils -w ./packages/runtime-utils -w ./packages/otel -w + ./packages/blobs -w ./packages/edge-functions -w ./packages/functions diff --git a/package-lock.json b/package-lock.json index 06f1ad0f..fc41a50c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19215,6 +19215,7 @@ "license": "MIT", "dependencies": { "@netlify/dev-utils": "4.1.1", + "@netlify/otel": "^3.1.0", "@netlify/runtime-utils": "2.1.0" }, "devDependencies": { diff --git a/package.json b/package.json index cf50a500..5f3edc02 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "packages/types", "packages/dev-utils", "packages/runtime-utils", + "packages/otel", "packages/blobs", "packages/cache", "packages/edge-functions", @@ -17,8 +18,7 @@ "packages/static", "packages/dev", "packages/nuxt-module", - "packages/vite-plugin", - "packages/otel" + "packages/vite-plugin" ], "version": "0.0.0", "scripts": { diff --git a/packages/blobs/package.json b/packages/blobs/package.json index 507d78f7..3a7295de 100644 --- a/packages/blobs/package.json +++ b/packages/blobs/package.json @@ -77,6 +77,7 @@ }, "dependencies": { "@netlify/dev-utils": "4.1.1", + "@netlify/otel": "^3.1.0", "@netlify/runtime-utils": "2.1.0" } } diff --git a/packages/blobs/src/store.ts b/packages/blobs/src/store.ts index b97d5fbe..170ce1f0 100644 --- a/packages/blobs/src/store.ts +++ b/packages/blobs/src/store.ts @@ -1,3 +1,4 @@ +import { getTracer, withActiveSpan } from '@netlify/otel' import { ListResponse, ListResponseBlob } from './backend/list.ts' import { Client, type Conditions } from './client.ts' import type { ConsistencyMode } from './consistency.ts' @@ -145,71 +146,101 @@ export class Store { } } - async get(key: string): Promise - async get(key: string, opts: GetOptions): Promise - async get(key: string, { type }: GetOptions & { type: 'arrayBuffer' }): Promise - async get(key: string, { type }: GetOptions & { type: 'blob' }): Promise - - 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 + async get(key: string, options?: GetOptions & { type?: 'arrayBuffer' }): Promise + async get(key: string, options?: GetOptions & { type?: 'blob' }): Promise + async get(key: string, options?: GetOptions & { type?: 'json' }): Promise + async get(key: string, options?: GetOptions & { type?: 'stream' }): Promise + async get(key: string, options?: GetOptions & { type?: 'text' }): Promise + async get(key: string, options?: GetOptions): Promise async get( key: string, options?: GetOptions & { type?: BlobResponseType }, ): Promise { - const { consistency, type } = options ?? {} - const res = await this.client.makeRequest({ consistency, key, method: HTTPMethod.GET, storeName: this.name }) - - if (res.status === 404) { - return null - } + return withActiveSpan(getTracer(), 'blobs.get', async (span) => { + const { consistency, type } = options ?? {} + + span?.setAttributes({ + 'blobs.store': this.name, + 'blobs.key': key, + 'blobs.type': type, + 'blobs.method': 'GET', + 'blobs.consistency': consistency, + }) + + const res = await this.client.makeRequest({ + consistency, + key, + method: HTTPMethod.GET, + storeName: this.name, + }) + + span?.setAttributes({ + 'blobs.response.status': res.status, + }) + + if (res.status === 404) { + return null + } - if (res.status !== 200) { - throw new BlobsInternalError(res) - } + if (res.status !== 200) { + throw new BlobsInternalError(res) + } - if (type === undefined || type === 'text') { - return res.text() - } + if (type === undefined || type === 'text') { + return res.text() + } - if (type === 'arrayBuffer') { - return res.arrayBuffer() - } + if (type === 'arrayBuffer') { + return res.arrayBuffer() + } - if (type === 'blob') { - return res.blob() - } + if (type === 'blob') { + return res.blob() + } - if (type === 'json') { - return res.json() - } + if (type === 'json') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return res.json() + } - if (type === 'stream') { - return res.body - } + if (type === 'stream') { + return res.body + } - throw new BlobsInternalError(res) + throw new BlobsInternalError(res) + }) } async getMetadata(key: string, { consistency }: { consistency?: ConsistencyMode } = {}) { - const res = await this.client.makeRequest({ consistency, key, method: HTTPMethod.HEAD, storeName: this.name }) - - if (res.status === 404) { - return null - } + return withActiveSpan(getTracer(), 'blobs.getMetadata', async (span) => { + span?.setAttributes({ + 'blobs.store': this.name, + 'blobs.key': key, + 'blobs.method': 'HEAD', + 'blobs.consistency': consistency, + }) + const res = await this.client.makeRequest({ consistency, key, method: HTTPMethod.HEAD, storeName: this.name }) + span?.setAttributes({ + 'blobs.response.status': res.status, + }) + + if (res.status === 404) { + return null + } - if (res.status !== 200 && res.status !== 304) { - throw new BlobsInternalError(res) - } + if (res.status !== 200 && res.status !== 304) { + throw new BlobsInternalError(res) + } - const etag = res?.headers.get('etag') ?? undefined - const metadata = getMetadataFromResponse(res) - const result = { - etag, - metadata, - } + const etag = res?.headers.get('etag') ?? undefined + const metadata = getMetadataFromResponse(res) + const result = { + etag, + metadata, + } - return result + return result + }) } async getWithMetadata( @@ -251,140 +282,195 @@ export class Store { } & GetWithMetadataResult) | null > { - const { consistency, etag: requestETag, type } = options ?? {} - const headers = requestETag ? { 'if-none-match': requestETag } : undefined - const res = await this.client.makeRequest({ - consistency, - headers, - key, - method: HTTPMethod.GET, - storeName: this.name, - }) - - if (res.status === 404) { - return null - } + return withActiveSpan(getTracer(), 'blobs.getWithMetadata', async (span) => { + const { consistency, etag: requestETag, type } = options ?? {} + const headers = requestETag ? { 'if-none-match': requestETag } : undefined + + span?.setAttributes({ + 'blobs.store': this.name, + 'blobs.key': key, + 'blobs.method': 'GET', + 'blobs.consistency': options?.consistency, + 'blobs.type': type, + 'blobs.request.etag': requestETag, + }) + + const res = await this.client.makeRequest({ + consistency, + headers, + key, + method: HTTPMethod.GET, + storeName: this.name, + }) + const responseETag = res?.headers.get('etag') ?? undefined + + span?.setAttributes({ + 'blobs.response.etag': responseETag, + 'blobs.response.status': res.status, + }) + + if (res.status === 404) { + return null + } - if (res.status !== 200 && res.status !== 304) { - throw new BlobsInternalError(res) - } + if (res.status !== 200 && res.status !== 304) { + throw new BlobsInternalError(res) + } - const responseETag = res?.headers.get('etag') ?? undefined - const metadata = getMetadataFromResponse(res) - const result: GetWithMetadataResult = { - etag: responseETag, - metadata, - } + const metadata = getMetadataFromResponse(res) + const result: GetWithMetadataResult = { + etag: responseETag, + metadata, + } - if (res.status === 304 && requestETag) { - return { data: null, ...result } - } + if (res.status === 304 && requestETag) { + return { data: null, ...result } + } - if (type === undefined || type === 'text') { - return { data: await res.text(), ...result } - } + if (type === undefined || type === 'text') { + return { data: await res.text(), ...result } + } - if (type === 'arrayBuffer') { - return { data: await res.arrayBuffer(), ...result } - } + if (type === 'arrayBuffer') { + return { data: await res.arrayBuffer(), ...result } + } - if (type === 'blob') { - return { data: await res.blob(), ...result } - } + if (type === 'blob') { + return { data: await res.blob(), ...result } + } - if (type === 'json') { - return { data: await res.json(), ...result } - } + if (type === 'json') { + return { data: await res.json(), ...result } + } - if (type === 'stream') { - return { data: res.body, ...result } - } + if (type === 'stream') { + return { data: res.body, ...result } + } - throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`) + throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`) + }) } list(options: ListOptions & { paginate: true }): AsyncIterable list(options?: ListOptions & { paginate?: false }): Promise list(options: ListOptions = {}): Promise | AsyncIterable { - const iterator = this.getListIterator(options) + return withActiveSpan(getTracer(), 'blobs.list', (span) => { + span?.setAttributes({ + 'blobs.store': this.name, + 'blobs.method': 'GET', + 'blobs.list.paginate': options.paginate ?? false, + }) - if (options.paginate) { - return iterator - } + const iterator = this.getListIterator(options) - // We can't use `async/await` here because that would make the signature - // incompatible with one of the overloads. - return collectIterator(iterator).then((items) => - items.reduce( - (acc, item) => ({ - blobs: [...acc.blobs, ...item.blobs], - directories: [...acc.directories, ...item.directories], - }), - { blobs: [], directories: [] }, - ), - ) - } + if (options.paginate) { + return iterator + } - async set(key: string, data: BlobInput, options: SetOptions = {}): Promise { - Store.validateKey(key) - - const conditions = Store.getConditions(options) - const res = await this.client.makeRequest({ - conditions, - body: data, - key, - metadata: options.metadata, - method: HTTPMethod.PUT, - storeName: this.name, + // We can't use `async/await` here because that would make the signature + // incompatible with one of the overloads. + return collectIterator(iterator).then((items) => + items.reduce( + (acc, item) => ({ + blobs: [...acc.blobs, ...item.blobs], + directories: [...acc.directories, ...item.directories], + }), + { blobs: [], directories: [] }, + ), + ) }) - const etag = res.headers.get('etag') ?? '' + } - if (conditions) { - return res.status === STATUS_PRE_CONDITION_FAILED ? { modified: false } : { etag, modified: true } - } + async set(key: string, data: BlobInput, options: SetOptions = {}): Promise { + return withActiveSpan(getTracer(), 'blobs.set', async (span) => { + span?.setAttributes({ + 'blobs.store': this.name, + 'blobs.key': key, + 'blobs.method': 'PUT', + 'blobs.data.size': typeof data == 'string' ? data.length : data instanceof Blob ? data.size : data.byteLength, + 'blobs.data.type': typeof data == 'string' ? 'string' : data instanceof Blob ? 'blob' : 'arrayBuffer', + 'blobs.atomic': Boolean(options.onlyIfMatch ?? options.onlyIfNew), + }) + + Store.validateKey(key) + + const conditions = Store.getConditions(options) + const res = await this.client.makeRequest({ + conditions, + body: data, + key, + metadata: options.metadata, + method: HTTPMethod.PUT, + storeName: this.name, + }) + + const etag = res.headers.get('etag') ?? '' + + span?.setAttributes({ + 'blobs.response.etag': etag, + 'blobs.response.status': res.status, + }) + + if (conditions) { + return res.status === STATUS_PRE_CONDITION_FAILED ? { modified: false } : { etag, modified: true } + } - if (res.status === STATUS_OK) { - return { - etag, - modified: true, + if (res.status === STATUS_OK) { + return { + etag, + modified: true, + } } - } - throw new BlobsInternalError(res) + throw new BlobsInternalError(res) + }) } async setJSON(key: string, data: unknown, options: SetOptions = {}): Promise { - Store.validateKey(key) - - const conditions = Store.getConditions(options) - const payload = JSON.stringify(data) - const headers = { - 'content-type': 'application/json', - } - - const res = await this.client.makeRequest({ - ...conditions, - body: payload, - headers, - key, - metadata: options.metadata, - method: HTTPMethod.PUT, - storeName: this.name, - }) - const etag = res.headers.get('etag') ?? '' + return withActiveSpan(getTracer(), 'blobs.setJSON', async (span) => { + span?.setAttributes({ + 'blobs.store': this.name, + 'blobs.key': key, + 'blobs.method': 'PUT', + 'blobs.data.type': 'json', + }) + Store.validateKey(key) + + const conditions = Store.getConditions(options) + const payload = JSON.stringify(data) + const headers = { + 'content-type': 'application/json', + } - if (conditions) { - return res.status === STATUS_PRE_CONDITION_FAILED ? { modified: false } : { etag, modified: true } - } + const res = await this.client.makeRequest({ + ...conditions, + body: payload, + headers, + key, + metadata: options.metadata, + method: HTTPMethod.PUT, + storeName: this.name, + }) + + const etag = res.headers.get('etag') ?? '' + span?.setAttributes({ + 'blobs.response.etag': etag, + 'blobs.response.status': res.status, + }) + + if (conditions) { + return res.status === STATUS_PRE_CONDITION_FAILED ? { modified: false } : { etag, modified: true } + } - if (res.status === STATUS_OK) { - return { - etag, - modified: true, + if (res.status === STATUS_OK) { + return { + etag, + modified: true, + } } - } - throw new BlobsInternalError(res) + throw new BlobsInternalError(res) + }) } private static formatListResultBlob(result: ListResponseBlob): ListResultBlob | null { @@ -485,51 +571,64 @@ export class Store { return { async next() { - if (done) { - return { done: true, value: undefined } - } + return withActiveSpan(getTracer(), 'blobs.list.next', async (span) => { + span?.setAttributes({ + 'blobs.store': storeName, + 'blobs.method': 'GET', + 'blobs.list.paginate': options?.paginate ?? false, + 'blobs.list.done': done, + 'blobs.list.cursor': currentCursor ?? undefined, + }) + if (done) { + return { done: true, value: undefined } as const + } - const nextParameters = { ...parameters } + const nextParameters = { ...parameters } - if (currentCursor !== null) { - nextParameters.cursor = currentCursor - } + if (currentCursor !== null) { + nextParameters.cursor = currentCursor + } - const res = await client.makeRequest({ - method: HTTPMethod.GET, - parameters: nextParameters, - storeName, - }) + const res = await client.makeRequest({ + method: HTTPMethod.GET, + parameters: nextParameters, + storeName, + }) - let blobs: ListResponseBlob[] = [] - let directories: string[] = [] + span?.setAttributes({ + 'blobs.response.status': res.status, + }) - if (![200, 204, 404].includes(res.status)) { - throw new BlobsInternalError(res) - } + let blobs: ListResponseBlob[] = [] + let directories: string[] = [] - if (res.status === 404) { - done = true - } else { - const page = (await res.json()) as ListResponse + if (![200, 204, 404].includes(res.status)) { + throw new BlobsInternalError(res) + } - if (page.next_cursor) { - currentCursor = page.next_cursor - } else { + if (res.status === 404) { done = true + } else { + const page = (await res.json()) as ListResponse + + if (page.next_cursor) { + currentCursor = page.next_cursor + } else { + done = true + } + + blobs = (page.blobs ?? []).map(Store.formatListResultBlob).filter(Boolean) as ListResponseBlob[] + directories = page.directories ?? [] } - blobs = (page.blobs ?? []).map(Store.formatListResultBlob).filter(Boolean) as ListResponseBlob[] - directories = page.directories ?? [] - } - - return { - done: false, - value: { - blobs, - directories, - }, - } + return { + done: false, + value: { + blobs, + directories, + }, + } + }) }, } },