Skip to content

Commit 6993a18

Browse files
authored
Support basic error exporting (#9227)
* Add support for exporting captured errors * Clarify flush() usage
1 parent 57c7798 commit 6993a18

File tree

12 files changed

+380
-86
lines changed

12 files changed

+380
-86
lines changed

packages/app/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { name as storageCompatName } from '../../../packages/storage-compat/pack
4141
import { name as firestoreName } from '../../../packages/firestore/package.json';
4242
import { name as aiName } from '../../../packages/ai/package.json';
4343
import { name as firestoreCompatName } from '../../../packages/firestore-compat/package.json';
44+
import { name as telemetryName } from '../../../packages/telemetry/package.json';
4445
import { name as packageName } from '../../../packages/firebase/package.json';
4546

4647
/**
@@ -74,6 +75,7 @@ export const PLATFORM_LOG_STRING = {
7475
[remoteConfigCompatName]: 'fire-rc-compat',
7576
[storageName]: 'fire-gcs',
7677
[storageCompatName]: 'fire-gcs-compat',
78+
[telemetryName]: 'fire-telemetry',
7779
[firestoreName]: 'fire-fst',
7880
[firestoreCompatName]: 'fire-fst-compat',
7981
[aiName]: 'fire-vertex',

packages/telemetry/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,13 @@
4242
"@firebase/app-types": "0.x"
4343
},
4444
"dependencies": {
45-
"tslib": "^2.1.0",
46-
"@firebase/component": "0.7.0"
45+
"@firebase/component": "0.7.0",
46+
"@opentelemetry/api-logs": "0.203.0",
47+
"@opentelemetry/exporter-logs-otlp-http": "0.203.0",
48+
"@opentelemetry/resources": "2.0.1",
49+
"@opentelemetry/sdk-logs": "0.203.0",
50+
"@opentelemetry/semantic-conventions": "1.36.0",
51+
"tslib": "^2.1.0"
4752
},
4853
"license": "Apache-2.0",
4954
"devDependencies": {

packages/telemetry/src/api.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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 } from 'chai';
19+
import { LoggerProvider } from '@opentelemetry/sdk-logs';
20+
import { Telemetry } from './public-types';
21+
import { Logger, LogRecord, SeverityNumber } from '@opentelemetry/api-logs';
22+
import { captureError, flush } from './api';
23+
24+
const emittedLogs: LogRecord[] = [];
25+
26+
const fakeLoggerProvider = {
27+
getLogger: (): Logger => {
28+
return {
29+
emit: (logRecord: LogRecord) => {
30+
emittedLogs.push(logRecord);
31+
}
32+
};
33+
},
34+
forceFlush: () => {
35+
emittedLogs.length = 0;
36+
return Promise.resolve();
37+
},
38+
shutdown: () => Promise.resolve()
39+
} as unknown as LoggerProvider;
40+
41+
const fakeTelemetry: Telemetry = {
42+
app: {
43+
name: 'DEFAULT',
44+
automaticDataCollectionEnabled: true,
45+
options: {
46+
projectId: 'my-project',
47+
appId: 'my-appid'
48+
}
49+
},
50+
loggerProvider: fakeLoggerProvider
51+
};
52+
53+
describe('Top level API', () => {
54+
beforeEach(() => {
55+
// Clear the logs before each test.
56+
emittedLogs.length = 0;
57+
});
58+
59+
describe('captureError()', () => {
60+
it('should capture an Error object correctly', () => {
61+
const error = new Error('This is a test error');
62+
error.stack = '...stack trace...';
63+
error.name = 'TestError';
64+
65+
captureError(fakeTelemetry, error);
66+
67+
expect(emittedLogs.length).to.equal(1);
68+
const log = emittedLogs[0];
69+
expect(log.severityNumber).to.equal(SeverityNumber.ERROR);
70+
expect(log.body).to.equal('This is a test error');
71+
expect(log.attributes).to.deep.equal({
72+
'error.type': 'TestError',
73+
'error.stack': '...stack trace...'
74+
});
75+
});
76+
77+
it('should handle an Error object with no stack trace', () => {
78+
const error = new Error('error with no stack');
79+
error.stack = undefined;
80+
81+
captureError(fakeTelemetry, error);
82+
83+
expect(emittedLogs.length).to.equal(1);
84+
const log = emittedLogs[0];
85+
expect(log.severityNumber).to.equal(SeverityNumber.ERROR);
86+
expect(log.body).to.equal('error with no stack');
87+
expect(log.attributes).to.deep.equal({
88+
'error.type': 'Error',
89+
'error.stack': 'No stack trace available'
90+
});
91+
});
92+
93+
it('should capture a string error correctly', () => {
94+
captureError(fakeTelemetry, 'a string error');
95+
96+
expect(emittedLogs.length).to.equal(1);
97+
const log = emittedLogs[0];
98+
expect(log.severityNumber).to.equal(SeverityNumber.ERROR);
99+
expect(log.body).to.equal('a string error');
100+
expect(log.attributes).to.be.undefined;
101+
});
102+
103+
it('should capture an unknown error type correctly', () => {
104+
captureError(fakeTelemetry, 12345);
105+
106+
expect(emittedLogs.length).to.equal(1);
107+
const log = emittedLogs[0];
108+
expect(log.severityNumber).to.equal(SeverityNumber.ERROR);
109+
expect(log.body).to.equal('Unknown error type: number');
110+
expect(log.attributes).to.be.undefined;
111+
});
112+
});
113+
114+
describe('flush()', () => {
115+
it('should flush logs correctly', async () => {
116+
captureError(fakeTelemetry, 'error1');
117+
captureError(fakeTelemetry, 'error2');
118+
119+
expect(emittedLogs.length).to.equal(2);
120+
121+
await flush(fakeTelemetry);
122+
123+
expect(emittedLogs.length).to.equal(0);
124+
});
125+
});
126+
});

packages/telemetry/src/api.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import { _getProvider, FirebaseApp, getApp } from '@firebase/app';
1919
import { TELEMETRY_TYPE } from './constants';
2020
import { Telemetry } from './public-types';
2121
import { Provider } from '@firebase/component';
22+
import { SeverityNumber } from '@opentelemetry/api-logs';
23+
import { TelemetryService } from './service';
24+
25+
declare module '@firebase/component' {
26+
interface NameServiceMapping {
27+
[TELEMETRY_TYPE]: TelemetryService;
28+
}
29+
}
2230

2331
/**
2432
* Returns the default {@link Telemetry} instance that is associated with the provided
@@ -44,3 +52,49 @@ export function getTelemetry(app: FirebaseApp = getApp()): Telemetry {
4452

4553
return telemetryProvider.getImmediate();
4654
}
55+
56+
/**
57+
* Enqueues an error to be uploaded to the Firebase Telemetry API.
58+
*
59+
* @public
60+
*
61+
* @param telemetry - The {@link Telemetry} instance.
62+
* @param error - the caught exception, typically an {@link Error}
63+
*/
64+
export function captureError(telemetry: Telemetry, error: unknown): void {
65+
const logger = telemetry.loggerProvider.getLogger('error-logger');
66+
if (error instanceof Error) {
67+
logger.emit({
68+
severityNumber: SeverityNumber.ERROR,
69+
body: error.message,
70+
attributes: {
71+
'error.type': error.name || 'Error',
72+
'error.stack': error.stack || 'No stack trace available'
73+
}
74+
});
75+
} else if (typeof error === 'string') {
76+
logger.emit({
77+
severityNumber: SeverityNumber.ERROR,
78+
body: error
79+
});
80+
} else {
81+
logger.emit({
82+
severityNumber: SeverityNumber.ERROR,
83+
body: `Unknown error type: ${typeof error}`
84+
});
85+
}
86+
}
87+
88+
/**
89+
* Flushes all enqueued telemetry data immediately, instead of waiting for default batching.
90+
*
91+
* @public
92+
*
93+
* @param telemetry - The {@link Telemetry} instance.
94+
* @returns a promise which is resolved when all flushes are complete
95+
*/
96+
export function flush(telemetry: Telemetry): Promise<void> {
97+
return telemetry.loggerProvider.forceFlush().catch(err => {
98+
console.error('Error flushing logs from Firebase Telemetry:', err);
99+
});
100+
}

packages/telemetry/src/helpers.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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 {
19+
LoggerProvider,
20+
BatchLogRecordProcessor
21+
} from '@opentelemetry/sdk-logs';
22+
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
23+
import { resourceFromAttributes } from '@opentelemetry/resources';
24+
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
25+
26+
/**
27+
* Create default logger provider.
28+
*
29+
* @internal
30+
*/
31+
export function createLoggerProvider(): LoggerProvider {
32+
const resource = resourceFromAttributes({
33+
[ATTR_SERVICE_NAME]: 'firebase_telemetry_service'
34+
});
35+
36+
const otlpEndpoint = process.env.OTEL_ENDPOINT;
37+
38+
const logExporter = new OTLPLogExporter({
39+
url: `${otlpEndpoint}/api/v1/logs`
40+
});
41+
return new LoggerProvider({
42+
resource,
43+
processors: [new BatchLogRecordProcessor(logExporter)]
44+
});
45+
}

packages/telemetry/src/index.node.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,11 @@
1616
*/
1717

1818
import { _registerComponent, registerVersion } from '@firebase/app';
19-
import { TestType } from './types/index';
2019
import { Component, ComponentType } from '@firebase/component';
2120
import { TELEMETRY_TYPE } from './constants';
2221
import { name, version } from '../package.json';
2322
import { TelemetryService } from './service';
24-
25-
export function testFxn(): number {
26-
const _thing: TestType = {};
27-
console.log('hi');
28-
return 42;
29-
}
30-
31-
declare module '@firebase/component' {
32-
interface NameServiceMapping {
33-
[TELEMETRY_TYPE]: TelemetryService;
34-
}
35-
}
23+
import { createLoggerProvider } from './helpers';
3624

3725
export function registerTelemetry(): void {
3826
_registerComponent(
@@ -41,7 +29,9 @@ export function registerTelemetry(): void {
4129
container => {
4230
// getImmediate for FirebaseApp will always succeed
4331
const app = container.getProvider('app').getImmediate();
44-
return new TelemetryService(app);
32+
const loggerProvider = createLoggerProvider();
33+
34+
return new TelemetryService(app, loggerProvider);
4535
},
4636
ComponentType.PUBLIC
4737
)

packages/telemetry/src/index.test.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

packages/telemetry/src/index.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,11 @@
1616
*/
1717

1818
import { _registerComponent, registerVersion } from '@firebase/app';
19-
import { TestType } from './types/index';
2019
import { Component, ComponentType } from '@firebase/component';
2120
import { TELEMETRY_TYPE } from './constants';
2221
import { name, version } from '../package.json';
2322
import { TelemetryService } from './service';
24-
25-
export function testFxn(): number {
26-
const _thing: TestType = {};
27-
console.log('hi');
28-
return 42;
29-
}
30-
31-
declare module '@firebase/component' {
32-
interface NameServiceMapping {
33-
[TELEMETRY_TYPE]: TelemetryService;
34-
}
35-
}
23+
import { createLoggerProvider } from './helpers';
3624

3725
export function registerTelemetry(): void {
3826
_registerComponent(
@@ -41,7 +29,9 @@ export function registerTelemetry(): void {
4129
(container, {}) => {
4230
// getImmediate for FirebaseApp will always succeed
4331
const app = container.getProvider('app').getImmediate();
44-
return new TelemetryService(app);
32+
const loggerProvider = createLoggerProvider();
33+
34+
return new TelemetryService(app, loggerProvider);
4535
},
4636
ComponentType.PUBLIC
4737
)

packages/telemetry/src/public-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import { FirebaseApp } from '@firebase/app';
19+
import { LoggerProvider } from '@opentelemetry/sdk-logs';
1920

2021
/**
2122
* An instance of the Firebase Telemetry SDK.
@@ -29,4 +30,7 @@ export interface Telemetry {
2930
* The {@link @firebase/app#FirebaseApp} this {@link Telemetry} instance is associated with.
3031
*/
3132
app: FirebaseApp;
33+
34+
/** The {@link LoggerProvider} this {@link Telemetry} instance uses. */
35+
loggerProvider: LoggerProvider;
3236
}

0 commit comments

Comments
 (0)