Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion common/api-review/telemetry.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,23 @@

```ts

import { AnyValueMap } from '@opentelemetry/api-logs';
import { FirebaseApp } from '@firebase/app';
import { Instrumentation } from 'next';
import { LoggerProvider } from '@opentelemetry/sdk-logs';

// @public
export function captureError(telemetry: Telemetry, error: unknown): void;
export function captureError(telemetry: Telemetry, error: unknown, attributes?: AnyValueMap): void;

// @public
export function flush(telemetry: Telemetry): Promise<void>;

// @public
export function getTelemetry(app?: FirebaseApp): Telemetry;

// @public
export const nextOnRequestError: Instrumentation.onRequestError;

// @public
export interface Telemetry {
app: FirebaseApp;
Expand Down
2 changes: 1 addition & 1 deletion packages/telemetry/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@

import { registerTelemetry } from './src/register.node';

console.log('Hi Node.js Users!');
registerTelemetry();

export * from './src/api';
export * from './src/public-types';
export * from './src/next';
1 change: 1 addition & 0 deletions packages/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ registerTelemetry();

export * from './src/api';
export * from './src/public-types';
export * from './src/next';
1 change: 1 addition & 0 deletions packages/telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@opentelemetry/resources": "2.0.1",
"@opentelemetry/sdk-logs": "0.203.0",
"@opentelemetry/semantic-conventions": "1.36.0",
"next": "15.5.2",
"tslib": "^2.1.0"
},
"license": "Apache-2.0",
Expand Down
28 changes: 28 additions & 0 deletions packages/telemetry/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,34 @@ describe('Top level API', () => {
'logging.googleapis.com/spanId': `my-span`
});
});

it('should propagate custom attributes', () => {
const error = new Error('This is a test error');
error.stack = '...stack trace...';
error.name = 'TestError';

captureError(fakeTelemetry, error, {
strAttr: 'string attribute',
mapAttr: {
boolAttr: true,
numAttr: 2
},
arrAttr: [1, 2, 3]
});

expect(emittedLogs.length).to.equal(1);
const log = emittedLogs[0];
expect(log.attributes).to.deep.equal({
'error.type': 'TestError',
'error.stack': '...stack trace...',
strAttr: 'string attribute',
mapAttr: {
boolAttr: true,
numAttr: 2
},
arrAttr: [1, 2, 3]
});
});
});

describe('flush()', () => {
Expand Down
20 changes: 15 additions & 5 deletions packages/telemetry/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,14 @@ export function getTelemetry(app: FirebaseApp = getApp()): Telemetry {
* @public
*
* @param telemetry - The {@link Telemetry} instance.
* @param error - the caught exception, typically an {@link Error}
* @param error - The caught exception, typically an {@link Error}
* @param attributes = Optional, arbitrary attributes to attach to the error log
*/
export function captureError(telemetry: Telemetry, error: unknown): void {
export function captureError(
telemetry: Telemetry,
error: unknown,
attributes?: AnyValueMap
): void {
const logger = telemetry.loggerProvider.getLogger('error-logger');

const activeSpanContext = trace.getActiveSpan()?.spanContext();
Expand All @@ -77,30 +82,35 @@ export function captureError(telemetry: Telemetry, error: unknown): void {
}
}

const customAttributes = attributes || {};

if (error instanceof Error) {
logger.emit({
severityNumber: SeverityNumber.ERROR,
body: error.message,
attributes: {
'error.type': error.name || 'Error',
'error.stack': error.stack || 'No stack trace available',
...traceAttributes
...traceAttributes,
...customAttributes
}
});
} else if (typeof error === 'string') {
logger.emit({
severityNumber: SeverityNumber.ERROR,
body: error,
attributes: {
...traceAttributes
...traceAttributes,
...customAttributes
}
});
} else {
logger.emit({
severityNumber: SeverityNumber.ERROR,
body: `Unknown error type: ${typeof error}`,
attributes: {
...traceAttributes
...traceAttributes,
...customAttributes
}
});
}
Expand Down
80 changes: 80 additions & 0 deletions packages/telemetry/src/next.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { expect, use } from 'chai';
import sinonChai from 'sinon-chai';
import chaiAsPromised from 'chai-as-promised';
import { restore, stub } from 'sinon';
import * as app from '@firebase/app';
import * as telemetry from './api';
import { FirebaseApp } from '@firebase/app';
import { Telemetry } from './public-types';
import { nextOnRequestError } from './next';

use(sinonChai);
use(chaiAsPromised);

describe('nextOnRequestError', () => {
let getTelemetryStub: sinon.SinonStub;
let captureErrorStub: sinon.SinonStub;
let fakeApp: FirebaseApp;
let fakeTelemetry: Telemetry;

beforeEach(() => {
fakeApp = {} as FirebaseApp;
fakeTelemetry = {} as Telemetry;

stub(app, 'getApp').returns(fakeApp);
getTelemetryStub = stub(telemetry, 'getTelemetry').returns(fakeTelemetry);
captureErrorStub = stub(telemetry, 'captureError');
});

afterEach(() => {
restore();
});

it('should capture errors with correct attributes', async () => {
const error = new Error('test error');
const errorRequest = {
path: '/test-path?some=param',
method: 'GET',
headers: {}
};
const errorContext: {
routerKind: 'Pages Router';
routePath: string;
routeType: 'render';
revalidateReason: undefined;
} = {
routerKind: 'Pages Router',
routePath: '/test-path',
routeType: 'render',
revalidateReason: undefined
};

await nextOnRequestError(error, errorRequest, errorContext);

expect(getTelemetryStub).to.have.been.calledOnceWith(fakeApp);
expect(captureErrorStub).to.have.been.calledOnceWith(fakeTelemetry, error, {
'nextjs_path': '/test-path?some=param',
'nextjs_method': 'GET',
'nextjs_router_kind': 'Pages Router',
'nextjs_route_path': '/test-path',
'nextjs_route_type': 'render'
});
});
});
50 changes: 50 additions & 0 deletions packages/telemetry/src/next.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { getApp } from '@firebase/app';
import { captureError, getTelemetry } from './api';
import { type Instrumentation } from 'next';

/**
* Automatically report uncaught errors from server routes to Firebase Telemetry.
*
* @example
* ```javascript
* // In instrumentation.ts (https://nextjs.org/docs/app/guides/instrumentation):
* import { nextOnRequestError } from '@firebase/telemetry/next';
* export const onRequestError = nextOnRequestError;
* ```
*
* @public
*/
export const nextOnRequestError: Instrumentation.onRequestError = async (
error,
errorRequest,
errorContext
) => {
const telemetry = getTelemetry(getApp());

const attributes = {
'nextjs_path': errorRequest.path,
'nextjs_method': errorRequest.method,
'nextjs_router_kind': errorContext.routerKind,
'nextjs_route_path': errorContext.routePath,
'nextjs_route_type': errorContext.routeType
};

captureError(telemetry, error, attributes);
};
Loading
Loading