Skip to content

Commit ac70ed0

Browse files
committed
Add next sub-package with automatic server error collection
1 parent ec64f4f commit ac70ed0

File tree

10 files changed

+553
-10
lines changed

10 files changed

+553
-10
lines changed

common/api-review/telemetry.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
55
```ts
66

7+
import { AnyValueMap } from '@opentelemetry/api-logs';
78
import { FirebaseApp } from '@firebase/app';
89
import { LoggerProvider } from '@opentelemetry/sdk-logs';
910

1011
// @public
11-
export function captureError(telemetry: Telemetry, error: unknown): void;
12+
export function captureError(telemetry: Telemetry, error: unknown, attributes?: AnyValueMap): void;
1213

1314
// @public
1415
export function flush(telemetry: Telemetry): Promise<void>;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"extends": "../../config/api-extractor.json",
3+
"mainEntryPointFilePath": "<projectFolder>/dist/src/next/index.d.ts",
4+
"dtsRollup": {
5+
"enabled": true,
6+
"untrimmedFilePath": "<projectFolder>/dist/next/index.d.ts"
7+
},
8+
"apiReport": {
9+
"enabled": false
10+
},
11+
"docModel": {
12+
"enabled": false
13+
},
14+
"tsdocMetadata": {
15+
"enabled": false
16+
}
17+
}

packages/telemetry/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
},
2020
"default": "./dist/index.esm.js"
2121
},
22+
"./next": {
23+
"types": "./dist/next/index.d.ts",
24+
"import": "./dist/next/index.esm.js",
25+
"require": "./dist/next/index.cjs.js"
26+
},
2227
"./package.json": "./package.json"
2328
},
2429
"files": [
@@ -27,7 +32,7 @@
2732
"scripts": {
2833
"lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
2934
"lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
30-
"build": "rollup -c && yarn api-report",
35+
"build": "rollup -c && yarn api-report && yarn api-report:next",
3136
"build:deps": "lerna run --scope @firebase/telemetry --include-dependencies build",
3237
"dev": "rollup -c -w",
3338
"test": "run-p --npm-path npm lint test:all",
@@ -37,6 +42,7 @@
3742
"test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.* --config ../../config/mocharc.node.js",
3843
"trusted-type-check": "tsec -p tsconfig.json --noEmit",
3944
"api-report": "api-extractor run --local --verbose",
45+
"api-report:next": "api-extractor run --config api-extractor.next.json --local --verbose",
4046
"typings:public": "node ../../scripts/build/use_typings.js ./dist/telemetry-public.d.ts"
4147
},
4248
"peerDependencies": {
@@ -51,6 +57,7 @@
5157
"@opentelemetry/resources": "2.0.1",
5258
"@opentelemetry/sdk-logs": "0.203.0",
5359
"@opentelemetry/semantic-conventions": "1.36.0",
60+
"next": "15.5.2",
5461
"tslib": "^2.1.0"
5562
},
5663
"license": "Apache-2.0",

packages/telemetry/rollup.config.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,41 @@ const nodeBuilds = [
7373
}
7474
];
7575

76-
export default [...browserBuilds, ...nodeBuilds];
76+
const nextBuilds = [
77+
{
78+
input: 'src/next/index.ts',
79+
output: {
80+
file: 'dist/next/index.esm.js',
81+
format: 'es',
82+
sourcemap: true
83+
},
84+
plugins: [
85+
typescriptPlugin({
86+
typescript,
87+
tsconfig: 'tsconfig.next.json',
88+
useTsconfigDeclarationDir: true
89+
}),
90+
json()
91+
],
92+
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
93+
},
94+
{
95+
input: 'src/next/index.ts',
96+
output: {
97+
file: 'dist/next/index.cjs.js',
98+
format: 'cjs',
99+
sourcemap: true
100+
},
101+
plugins: [
102+
typescriptPlugin({
103+
typescript,
104+
tsconfig: 'tsconfig.next.json',
105+
useTsconfigDeclarationDir: true
106+
}),
107+
json()
108+
],
109+
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
110+
}
111+
];
112+
113+
export default [...browserBuilds, ...nodeBuilds, ...nextBuilds];

packages/telemetry/src/api.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,34 @@ describe('Top level API', () => {
146146
'logging.googleapis.com/spanId': `my-span`
147147
});
148148
});
149+
150+
it('should propagate custom attributes', () => {
151+
const error = new Error('This is a test error');
152+
error.stack = '...stack trace...';
153+
error.name = 'TestError';
154+
155+
captureError(fakeTelemetry, error, {
156+
strAttr: 'string attribute',
157+
mapAttr: {
158+
boolAttr: true,
159+
numAttr: 2,
160+
},
161+
arrAttr: [1, 2, 3],
162+
});
163+
164+
expect(emittedLogs.length).to.equal(1);
165+
const log = emittedLogs[0];
166+
expect(log.attributes).to.deep.equal({
167+
'error.type': 'TestError',
168+
'error.stack': '...stack trace...',
169+
strAttr: 'string attribute',
170+
mapAttr: {
171+
boolAttr: true,
172+
numAttr: 2,
173+
},
174+
arrAttr: [1, 2, 3],
175+
});
176+
});
149177
});
150178

151179
describe('flush()', () => {

packages/telemetry/src/api.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,14 @@ export function getTelemetry(app: FirebaseApp = getApp()): Telemetry {
6060
* @public
6161
*
6262
* @param telemetry - The {@link Telemetry} instance.
63-
* @param error - the caught exception, typically an {@link Error}
63+
* @param error - The caught exception, typically an {@link Error}
64+
* @param attributes = Optional, arbitrary attributes to attach to the error log
6465
*/
65-
export function captureError(telemetry: Telemetry, error: unknown): void {
66+
export function captureError(
67+
telemetry: Telemetry,
68+
error: unknown,
69+
attributes?: AnyValueMap
70+
): void {
6671
const logger = telemetry.loggerProvider.getLogger('error-logger');
6772

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

85+
const customAttributes = attributes || {};
86+
8087
if (error instanceof Error) {
8188
logger.emit({
8289
severityNumber: SeverityNumber.ERROR,
8390
body: error.message,
8491
attributes: {
8592
'error.type': error.name || 'Error',
8693
'error.stack': error.stack || 'No stack trace available',
87-
...traceAttributes
94+
...traceAttributes,
95+
...customAttributes
8896
}
8997
});
9098
} else if (typeof error === 'string') {
9199
logger.emit({
92100
severityNumber: SeverityNumber.ERROR,
93101
body: error,
94102
attributes: {
95-
...traceAttributes
103+
...traceAttributes,
104+
...customAttributes
96105
}
97106
});
98107
} else {
99108
logger.emit({
100109
severityNumber: SeverityNumber.ERROR,
101110
body: `Unknown error type: ${typeof error}`,
102111
attributes: {
103-
...traceAttributes
112+
...traceAttributes,
113+
...customAttributes
104114
}
105115
});
106116
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect, use } from 'chai';
19+
import sinonChai from 'sinon-chai';
20+
import chaiAsPromised from 'chai-as-promised';
21+
import { restore, stub } from 'sinon';
22+
import { onRequestError } from './index';
23+
import * as app from '@firebase/app';
24+
import * as telemetry from '../api';
25+
import { FirebaseApp } from '@firebase/app';
26+
import { Telemetry } from '../public-types';
27+
28+
use(sinonChai);
29+
use(chaiAsPromised);
30+
31+
describe('onRequestError', () => {
32+
let getTelemetryStub: sinon.SinonStub;
33+
let captureErrorStub: sinon.SinonStub;
34+
let fakeApp: FirebaseApp;
35+
let fakeTelemetry: Telemetry;
36+
37+
beforeEach(() => {
38+
fakeApp = {} as FirebaseApp;
39+
fakeTelemetry = {} as Telemetry;
40+
41+
stub(app, 'getApp').returns(fakeApp);
42+
getTelemetryStub = stub(telemetry, 'getTelemetry').returns(fakeTelemetry);
43+
captureErrorStub = stub(telemetry, 'captureError');
44+
});
45+
46+
afterEach(() => {
47+
restore();
48+
});
49+
50+
it('should capture errors with correct attributes', async () => {
51+
const error = new Error('test error');
52+
const errorRequest = {
53+
path: '/test-path?some=param',
54+
method: 'GET',
55+
headers: {},
56+
};
57+
const errorContext: {
58+
routerKind: 'Pages Router';
59+
routePath: string;
60+
routeType: 'render';
61+
revalidateReason: undefined;
62+
} = {
63+
routerKind: 'Pages Router',
64+
routePath: '/test-path',
65+
routeType: 'render',
66+
revalidateReason: undefined,
67+
};
68+
69+
await onRequestError(error, errorRequest, errorContext);
70+
71+
expect(getTelemetryStub).to.have.been.calledOnceWith(fakeApp);
72+
expect(captureErrorStub).to.have.been.calledOnceWith(
73+
fakeTelemetry,
74+
error,
75+
{
76+
'nextjs_path': '/test-path?some=param',
77+
'nextjs_method': 'GET',
78+
'nextjs_router_kind': 'Pages Router',
79+
'nextjs_route_path': '/test-path',
80+
'nextjs_route_type': 'render'
81+
}
82+
);
83+
});
84+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { getApp } from '@firebase/app';
19+
// import { captureError, getTelemetry } from '@firebase/telemetry';
20+
import { captureError, getTelemetry } from '../api';
21+
import { type Instrumentation } from 'next';
22+
23+
/**
24+
* Automatically report uncaught errors from server routes to Firebase Telemetry.
25+
*
26+
* @example
27+
* ```javascript
28+
* // In instrumentation.ts (https://nextjs.org/docs/app/guides/instrumentation):
29+
* import { onRequestError as firebaseTelemetryOnRequestError } from '@firebase/telemetry/next';
30+
* export const onRequestError = firebaseTelemetryOnRequestError;
31+
* ```
32+
*
33+
* @public
34+
*/
35+
export const onRequestError: Instrumentation.onRequestError = async (
36+
error,
37+
errorRequest,
38+
errorContext
39+
) => {
40+
const telemetry = getTelemetry(getApp());
41+
42+
const attributes = {
43+
'nextjs_path': errorRequest.path,
44+
'nextjs_method': errorRequest.method,
45+
'nextjs_router_kind': errorContext.routerKind,
46+
'nextjs_route_path': errorContext.routePath,
47+
'nextjs_route_type': errorContext.routeType
48+
};
49+
50+
captureError(telemetry, error, attributes);
51+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"declaration": true,
6+
"declarationDir": "dist",
7+
"rootDir": "./"
8+
},
9+
"include": [
10+
"src/next/index.ts"
11+
]
12+
}

0 commit comments

Comments
 (0)