Skip to content

Commit d24986a

Browse files
authored
Add support for async attributes in temeletry otel logger (#9373)
1 parent 836a427 commit d24986a

File tree

8 files changed

+249
-8
lines changed

8 files changed

+249
-8
lines changed

packages/telemetry/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,10 @@
9090
},
9191
"dependencies": {
9292
"@firebase/component": "0.7.0",
93+
"@firebase/installations": "0.6.19",
9394
"@opentelemetry/api": "1.9.0",
9495
"@opentelemetry/api-logs": "0.203.0",
96+
"@opentelemetry/core": "2.2.0",
9597
"@opentelemetry/exporter-logs-otlp-http": "0.203.0",
9698
"@opentelemetry/otlp-exporter-base": "0.205.0",
9799
"@opentelemetry/otlp-transformer": "0.205.0",

packages/telemetry/src/api.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,11 @@ import { FirebaseAppCheckInternal } from '@firebase/app-check-interop-types';
3737
import { captureError, flush, getTelemetry } from './api';
3838
import { TelemetryService } from './service';
3939
import { registerTelemetry } from './register';
40+
import { _FirebaseInstallationsInternal } from '@firebase/installations';
4041

4142
const PROJECT_ID = 'my-project';
4243
const APP_ID = 'my-appid';
44+
const API_KEY = 'my-api-key';
4345

4446
const emittedLogs: LogRecord[] = [];
4547

@@ -263,6 +265,17 @@ describe('Top level API', () => {
263265

264266
function getFakeApp(): FirebaseApp {
265267
registerTelemetry();
268+
_registerComponent(
269+
new Component(
270+
'installations-internal',
271+
() =>
272+
({
273+
getId: async () => 'iid',
274+
getToken: async () => 'authToken'
275+
} as _FirebaseInstallationsInternal),
276+
ComponentType.PUBLIC
277+
)
278+
);
266279
_registerComponent(
267280
new Component(
268281
'app-check-internal',
@@ -272,7 +285,11 @@ function getFakeApp(): FirebaseApp {
272285
ComponentType.PUBLIC
273286
)
274287
);
275-
const app = initializeApp({});
288+
const app = initializeApp({
289+
projectId: PROJECT_ID,
290+
appId: APP_ID,
291+
apiKey: API_KEY
292+
});
276293
_addOrOverwriteComponent(
277294
app,
278295
//@ts-ignore
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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 { InstallationIdProvider } from './installation-id-provider';
19+
import { _FirebaseInstallationsInternal } from '@firebase/installations';
20+
import { expect } from 'chai';
21+
22+
describe('InstallationIdProvider', () => {
23+
it('should cache the installation id after the first call', async () => {
24+
let callCount = 0;
25+
const mockInstallations = {
26+
getId: async () => {
27+
callCount++;
28+
return 'iid-123';
29+
}
30+
} as unknown as _FirebaseInstallationsInternal;
31+
32+
const mockProvider = {
33+
getImmediate: () => mockInstallations,
34+
get: async () => mockInstallations
35+
} as any;
36+
37+
const provider = new InstallationIdProvider(mockProvider);
38+
39+
const attr1 = await provider.getAttribute();
40+
expect(attr1).to.deep.equal(['user.id', 'iid-123']);
41+
expect(callCount).to.equal(1);
42+
43+
const attr2 = await provider.getAttribute();
44+
expect(attr2).to.deep.equal(['user.id', 'iid-123']);
45+
expect(callCount).to.equal(1); // Should still be 1
46+
});
47+
48+
it('should not cache if installation id is null', async () => {
49+
let callCount = 0;
50+
let returnValue: string | null = null;
51+
const mockInstallations = {
52+
getId: async () => {
53+
callCount++;
54+
return returnValue;
55+
}
56+
} as unknown as _FirebaseInstallationsInternal;
57+
58+
const mockProvider = {
59+
getImmediate: () => mockInstallations,
60+
get: async () => mockInstallations
61+
} as any;
62+
63+
const provider = new InstallationIdProvider(mockProvider);
64+
65+
const attr1 = await provider.getAttribute();
66+
expect(attr1).to.be.null;
67+
expect(callCount).to.equal(1);
68+
69+
returnValue = 'iid-456';
70+
const attr2 = await provider.getAttribute();
71+
expect(attr2).to.deep.equal(['user.id', 'iid-456']);
72+
expect(callCount).to.equal(2);
73+
74+
// Should cache now
75+
const attr3 = await provider.getAttribute();
76+
expect(attr3).to.deep.equal(['user.id', 'iid-456']);
77+
expect(callCount).to.equal(2);
78+
});
79+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 { Provider } from '@firebase/component';
19+
import { DynamicLogAttributeProvider, LogEntryAttribute } from '../types';
20+
import { _FirebaseInstallationsInternal } from '@firebase/installations';
21+
22+
/**
23+
* Allows logging to include the client's installation ID.
24+
*
25+
* @internal
26+
*/
27+
export class InstallationIdProvider implements DynamicLogAttributeProvider {
28+
private installations: _FirebaseInstallationsInternal | null;
29+
private _iid: string | undefined;
30+
31+
constructor(installationsProvider: Provider<'installations-internal'>) {
32+
this.installations = installationsProvider?.getImmediate({
33+
optional: true
34+
});
35+
if (!this.installations) {
36+
void installationsProvider
37+
?.get()
38+
.then(installations => (this.installations = installations))
39+
.catch();
40+
}
41+
}
42+
43+
async getAttribute(): Promise<LogEntryAttribute | null> {
44+
if (!this.installations) {
45+
return null;
46+
}
47+
if (this._iid) {
48+
return ['user.id', this._iid];
49+
}
50+
51+
const iid = await this.installations.getId();
52+
if (!iid) {
53+
return null;
54+
}
55+
56+
this._iid = iid;
57+
return ['user.id', iid];
58+
}
59+
}

packages/telemetry/src/logging/logger-provider.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ import {
3030
createOtlpNetworkExportDelegate
3131
} from '@opentelemetry/otlp-exporter-base';
3232
import { FetchTransport } from './fetch-transport';
33-
import { DynamicHeaderProvider } from '../types';
33+
import { DynamicHeaderProvider, DynamicLogAttributeProvider } from '../types';
3434
import { FirebaseApp } from '@firebase/app';
35+
import { ExportResult } from '@opentelemetry/core';
3536

3637
/**
3738
* Create a logger provider for the current execution environment.
@@ -41,7 +42,8 @@ import { FirebaseApp } from '@firebase/app';
4142
export function createLoggerProvider(
4243
app: FirebaseApp,
4344
endpointUrl: string,
44-
dynamicHeaderProviders: DynamicHeaderProvider[] = []
45+
dynamicHeaderProviders: DynamicHeaderProvider[] = [],
46+
dynamicLogAttributeProviders: DynamicLogAttributeProvider[] = []
4547
): LoggerProvider {
4648
const resource = resourceFromAttributes({
4749
[ATTR_SERVICE_NAME]: 'firebase_telemetry_service'
@@ -64,11 +66,48 @@ export function createLoggerProvider(
6466

6567
return new LoggerProvider({
6668
resource,
67-
processors: [new BatchLogRecordProcessor(logExporter)],
69+
processors: [
70+
new BatchLogRecordProcessor(
71+
new AsyncAttributeLogExporter(logExporter, dynamicLogAttributeProviders)
72+
)
73+
],
6874
logRecordLimits: {}
6975
});
7076
}
7177

78+
/** A log exporter that appends log entries with resolved async attributes before exporting. */
79+
class AsyncAttributeLogExporter implements LogRecordExporter {
80+
private readonly _delegate: LogRecordExporter;
81+
82+
constructor(
83+
exporter: OTLPLogExporter,
84+
private dynamicLogAttributeProviders: DynamicLogAttributeProvider[]
85+
) {
86+
this._delegate = exporter;
87+
}
88+
89+
async export(
90+
logs: ReadableLogRecord[],
91+
resultCallback: (result: ExportResult) => void
92+
): Promise<void> {
93+
await Promise.all(
94+
this.dynamicLogAttributeProviders.map(async provider => {
95+
const attribute = await provider.getAttribute();
96+
if (attribute) {
97+
logs.forEach(log => {
98+
log.attributes[attribute[0]] = attribute[1];
99+
});
100+
}
101+
})
102+
);
103+
this._delegate.export(logs, resultCallback);
104+
}
105+
106+
shutdown(): Promise<void> {
107+
return this._delegate.shutdown();
108+
}
109+
}
110+
72111
/** OTLP exporter that uses custom FetchTransport. */
73112
class OTLPLogExporter
74113
extends OTLPExporterBase<ReadableLogRecord[]>

packages/telemetry/src/register.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ import { name, version } from '../package.json';
2222
import { TelemetryService } from './service';
2323
import { createLoggerProvider } from './logging/logger-provider';
2424
import { AppCheckProvider } from './logging/appcheck-provider';
25+
import { InstallationIdProvider } from './logging/installation-id-provider';
26+
27+
// We only import types from this package elsewhere in the `telemetry` package, so this
28+
// explicit import is needed here to prevent this module from being tree-shaken out.
29+
import '@firebase/installations';
2530

2631
export function registerTelemetry(): void {
2732
_registerComponent(
@@ -38,11 +43,18 @@ export function registerTelemetry(): void {
3843
// getImmediate for FirebaseApp will always succeed
3944
const app = container.getProvider('app').getImmediate();
4045
const appCheckProvider = container.getProvider('app-check-internal');
46+
const installationsProvider = container.getProvider(
47+
'installations-internal'
48+
);
4149
const dynamicHeaderProviders = [new AppCheckProvider(appCheckProvider)];
50+
const dynamicLogAttributeProviders = [
51+
new InstallationIdProvider(installationsProvider)
52+
];
4253
const loggerProvider = createLoggerProvider(
4354
app,
4455
endpointUrl,
45-
dynamicHeaderProviders
56+
dynamicHeaderProviders,
57+
dynamicLogAttributeProviders
4658
);
4759

4860
return new TelemetryService(app, loggerProvider);

packages/telemetry/src/types.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,38 @@
1515
* limitations under the License.
1616
*/
1717

18+
type KeyValuePair = [key: string, value: string];
19+
20+
/**
21+
* A type for Cloud Logging log entry attributes
22+
*
23+
* @internal
24+
*/
25+
export type LogEntryAttribute = KeyValuePair;
26+
27+
/**
28+
* An interface for classes that provide dynamic log entry attributes.
29+
*
30+
* Classes that implement this interface can be used to supply custom headers for logging.
31+
*
32+
* @internal
33+
*/
34+
export interface DynamicLogAttributeProvider {
35+
/**
36+
* Returns a record of attributes to be added to a log entry.
37+
*
38+
* @returns A {@link Promise} that resolves to a {@link LogEntryAttribute} key-value pair,
39+
* or null if no attribute is to be added.
40+
*/
41+
getAttribute(): Promise<LogEntryAttribute | null>;
42+
}
43+
1844
/**
1945
* A type for HTTP Headers
2046
*
2147
* @internal
2248
*/
23-
export type HttpHeader = [key: string, value: string];
49+
export type HttpHeader = KeyValuePair;
2450

2551
/**
2652
* An interface for classes that provide dynamic headers.
@@ -33,8 +59,8 @@ export interface DynamicHeaderProvider {
3359
/**
3460
* Returns a record of headers to be added to a request.
3561
*
36-
* @returns A {@link Promise} that resolves to a {@link Record<string, string>} of header
37-
* key-value pairs, or null if no headers are to be added.
62+
* @returns A {@link Promise} that resolves to a {@link HttpHeader} key-value pair,
63+
* or null if no header is to be added.
3864
*/
3965
getHeader(): Promise<HttpHeader | null>;
4066
}

yarn.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,6 +2777,13 @@
27772777
dependencies:
27782778
"@opentelemetry/semantic-conventions" "^1.29.0"
27792779

2780+
"@opentelemetry/[email protected]":
2781+
version "2.2.0"
2782+
resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz#2f857d7790ff160a97db3820889b5f4cade6eaee"
2783+
integrity sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==
2784+
dependencies:
2785+
"@opentelemetry/semantic-conventions" "^1.29.0"
2786+
27802787
"@opentelemetry/[email protected]":
27812788
version "0.203.0"
27822789
resolved "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.203.0.tgz#cdecb5c5b39561aa8520c8bb78347c6e11c91a81"

0 commit comments

Comments
 (0)