Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion common/api-review/telemetry.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

```ts

import { AnyValueMap } from '@opentelemetry/api-logs';
import { FirebaseApp } from '@firebase/app';
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>;
Expand Down
17 changes: 17 additions & 0 deletions packages/telemetry/api-extractor.next.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"extends": "../../config/api-extractor.json",
"mainEntryPointFilePath": "<projectFolder>/dist/src/next/index.d.ts",
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "<projectFolder>/dist/next/index.d.ts"
},
"apiReport": {
"enabled": false
},
"docModel": {
"enabled": false
},
"tsdocMetadata": {
"enabled": false
}
}
9 changes: 8 additions & 1 deletion packages/telemetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
},
"default": "./dist/index.esm.js"
},
"./next": {
"types": "./dist/next/index.d.ts",
"import": "./dist/next/index.esm.js",
"require": "./dist/next/index.cjs.js"
},
"./package.json": "./package.json"
},
"files": [
Expand All @@ -27,7 +32,7 @@
"scripts": {
"lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
"lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
"build": "rollup -c && yarn api-report",
"build": "rollup -c && yarn api-report && yarn api-report:next",
"build:deps": "lerna run --scope @firebase/telemetry --include-dependencies build",
"dev": "rollup -c -w",
"test": "run-p --npm-path npm lint test:all",
Expand All @@ -37,6 +42,7 @@
"test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.* --config ../../config/mocharc.node.js",
"trusted-type-check": "tsec -p tsconfig.json --noEmit",
"api-report": "api-extractor run --local --verbose",
"api-report:next": "api-extractor run --config api-extractor.next.json --local --verbose",
"typings:public": "node ../../scripts/build/use_typings.js ./dist/telemetry-public.d.ts"
},
"peerDependencies": {
Expand All @@ -51,6 +57,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
39 changes: 38 additions & 1 deletion packages/telemetry/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,41 @@ const nodeBuilds = [
}
];

export default [...browserBuilds, ...nodeBuilds];
const nextBuilds = [
{
input: 'src/next/index.ts',
output: {
file: 'dist/next/index.esm.js',
format: 'es',
sourcemap: true
},
plugins: [
typescriptPlugin({
typescript,
tsconfig: 'tsconfig.next.json',
useTsconfigDeclarationDir: true
}),
json()
],
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
},
{
input: 'src/next/index.ts',
output: {
file: 'dist/next/index.cjs.js',
format: 'cjs',
sourcemap: true
},
plugins: [
typescriptPlugin({
typescript,
tsconfig: 'tsconfig.next.json',
useTsconfigDeclarationDir: true
}),
json()
],
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
}
];

export default [...browserBuilds, ...nodeBuilds, ...nextBuilds];
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/index.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 { onRequestError } from './index';
import * as app from '@firebase/app';
import * as telemetry from '../api';
import { FirebaseApp } from '@firebase/app';
import { Telemetry } from '../public-types';

use(sinonChai);
use(chaiAsPromised);

describe('onRequestError', () => {
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 onRequestError(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'
});
});
});
53 changes: 53 additions & 0 deletions packages/telemetry/src/next/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* @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';
import { registerTelemetry } from '../register';

registerTelemetry();

/**
* Automatically report uncaught errors from server routes to Firebase Telemetry.
*
* @example
* ```javascript
* // In instrumentation.ts (https://nextjs.org/docs/app/guides/instrumentation):
* import { onRequestError as firebaseTelemetryOnRequestError } from '@firebase/telemetry/next';
* export const onRequestError = firebaseTelemetryOnRequestError;
* ```
*
* @public
*/
export const onRequestError: 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);
};
12 changes: 12 additions & 0 deletions packages/telemetry/tsconfig.next.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationDir": "dist",
"rootDir": "./"
},
"include": [
"src/next/index.ts"
]
}
Loading
Loading