Skip to content
Open
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
57 changes: 57 additions & 0 deletions packages/telemetry/src/logging/appcheck-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* @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 { DynamicHeaderProvider } from '../types';
import { Provider } from '@firebase/component';
import {
FirebaseAppCheckInternal,
AppCheckInternalComponentName
} from '@firebase/app-check-interop-types';

/**
* An implementation of DynamicHeaderProvider that can be used to provide App Check token headers.
*
* @internal
*/
export class AppCheckProvider implements DynamicHeaderProvider {
appCheck: FirebaseAppCheckInternal | null;

constructor(appCheckProvider: Provider<AppCheckInternalComponentName>) {
this.appCheck = appCheckProvider?.getImmediate({ optional: true });
if (!this.appCheck) {
void appCheckProvider
?.get()
.then(appCheck => (this.appCheck = appCheck))
.catch();
}
}

async getHeader(): Promise<Record<string, string> | null> {
if (!this.appCheck) {
return null;
}

const appCheckToken = await this.appCheck.getToken();
// The error field must be checked as when there is an error, the token field is populated with
// a dummy token.
if (!appCheckToken || !!appCheckToken.error) {
return null;
}

return { 'X-Firebase-AppCheck': appCheckToken.token };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@

import * as sinon from 'sinon';
import * as assert from 'assert';
import { FetchTransportEdge } from './fetch-transport.edge';
import { DynamicHeaderProvider } from '../types';
import { FetchTransport } from './fetch-transport';
import {
ExportResponseRetryable,
ExportResponseFailure,
Expand All @@ -39,7 +40,7 @@ const testTransportParameters = {
const requestTimeout = 1000;
const testPayload = Uint8Array.from([1, 2, 3]);

describe('FetchTransportEdge', () => {
describe('FetchTransport', () => {
afterEach(() => {
sinon.restore();
});
Expand All @@ -50,7 +51,7 @@ describe('FetchTransportEdge', () => {
const fetchStub = sinon
.stub(globalThis, 'fetch')
.resolves(new Response('test response', { status: 200 }));
const transport = new FetchTransportEdge(testTransportParameters);
const transport = new FetchTransport(testTransportParameters);

//act
transport.send(testPayload, requestTimeout).then(response => {
Expand Down Expand Up @@ -87,7 +88,7 @@ describe('FetchTransportEdge', () => {
sinon
.stub(globalThis, 'fetch')
.resolves(new Response('', { status: 404 }));
const transport = new FetchTransportEdge(testTransportParameters);
const transport = new FetchTransport(testTransportParameters);

//act
transport.send(testPayload, requestTimeout).then(response => {
Expand All @@ -108,7 +109,7 @@ describe('FetchTransportEdge', () => {
.resolves(
new Response('', { status: 503, headers: { 'Retry-After': '5' } })
);
const transport = new FetchTransportEdge(testTransportParameters);
const transport = new FetchTransport(testTransportParameters);

//act
transport.send(testPayload, requestTimeout).then(response => {
Expand All @@ -132,7 +133,7 @@ describe('FetchTransportEdge', () => {
abortError.name = 'AbortError';
sinon.stub(globalThis, 'fetch').rejects(abortError);
const clock = sinon.useFakeTimers();
const transport = new FetchTransportEdge(testTransportParameters);
const transport = new FetchTransport(testTransportParameters);

//act
transport.send(testPayload, requestTimeout).then(response => {
Expand All @@ -155,7 +156,7 @@ describe('FetchTransportEdge', () => {
// arrange
sinon.stub(globalThis, 'fetch').throws(new Error('fetch failed'));
const clock = sinon.useFakeTimers();
const transport = new FetchTransportEdge(testTransportParameters);
const transport = new FetchTransport(testTransportParameters);

//act
transport.send(testPayload, requestTimeout).then(response => {
Expand All @@ -173,5 +174,82 @@ describe('FetchTransportEdge', () => {
}, done /* catch any rejections */);
clock.tick(requestTimeout + 100);
});

it('attaches static and dynamic headers to the request', done => {
// arrange
const fetchStub = sinon
.stub(globalThis, 'fetch')
.resolves(new Response('test response', { status: 200 }));

const dynamicProvider: DynamicHeaderProvider = {
getHeader: sinon.stub().resolves({ 'dynamic-header': 'dynamic-value' })
};

const transport = new FetchTransport({
...testTransportParameters,
dynamicHeaders: [dynamicProvider]
});

//act
transport.send(testPayload, requestTimeout).then(response => {
// assert
try {
assert.strictEqual(response.status, 'success');
sinon.assert.calledOnceWithMatch(
fetchStub,
testTransportParameters.url,
{
method: 'POST',
headers: {
foo: 'foo-value',
bar: 'bar-value',
'Content-Type': 'application/json',
'dynamic-header': 'dynamic-value'
},
body: testPayload
}
);
done();
} catch (e) {
done(e);
}
}, done /* catch any rejections */);
});

it('handles dynamic header providers that return null', done => {
// arrange
const fetchStub = sinon
.stub(globalThis, 'fetch')
.resolves(new Response('test response', { status: 200 }));

const dynamicProvider: DynamicHeaderProvider = {
getHeader: sinon.stub().resolves(null)
};

const transport = new FetchTransport({
...testTransportParameters,
dynamicHeaders: [dynamicProvider]
});

//act
transport.send(testPayload, requestTimeout).then(response => {
// assert
try {
assert.strictEqual(response.status, 'success');
sinon.assert.calledOnceWithMatch(
fetchStub,
testTransportParameters.url,
{
method: 'POST',
headers: testTransportParameters.headers(),
body: testPayload
}
);
done();
} catch (e) {
done(e);
}
}, done /* catch any rejections */);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ExportResponse
} from '@opentelemetry/otlp-exporter-base';
import { diag } from '@opentelemetry/api';
import { DynamicHeaderProvider } from '../types';

function isExportRetryable(statusCode: number): boolean {
const retryCodes = [429, 502, 503, 504];
Expand Down Expand Up @@ -53,24 +54,43 @@ function parseRetryAfterToMills(
export interface FetchTransportParameters {
url: string;
headers: () => Record<string, string>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to use Headers()? Just a suggestion https://developer.mozilla.org/en-US/docs/Web/API/Headers/Headers Doesn't offer any real advantages other than clearer typing.

dynamicHeaders?: DynamicHeaderProvider[];
}

/**
* An implementation of IExporterTransport that can be used in the Edge Runtime.
* An implementation of IExporterTransport.
*
* @internal
*/
export class FetchTransportEdge implements IExporterTransport {
export class FetchTransport implements IExporterTransport {
constructor(private parameters: FetchTransportParameters) {}

async send(data: Uint8Array, timeoutMillis: number): Promise<ExportResponse> {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), timeoutMillis);
try {
const url = new URL(this.parameters.url);
const headers = this.parameters.headers();

if (
this.parameters.dynamicHeaders &&
this.parameters.dynamicHeaders.length > 0
) {
const dynamicHeaderPromises = this.parameters.dynamicHeaders.map(
provider => provider.getHeader()
);
const resolvedHeaders = await Promise.all(dynamicHeaderPromises);

for (const header of resolvedHeaders) {
if (header) {
Object.assign(headers, header);
}
}
}

const body = {
method: 'POST',
headers: this.parameters.headers(),
headers,
signal: abortController.signal,
keepalive: false,
mode: 'cors',
Expand Down
49 changes: 24 additions & 25 deletions packages/telemetry/src/logging/logger-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,54 +23,52 @@ import {
} from '@opentelemetry/sdk-logs';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer';
import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base';
import {
OTLPExporterBase,
createOtlpNetworkExportDelegate
} from '@opentelemetry/otlp-exporter-base';
import { FetchTransportEdge } from './fetch-transport.edge';
import { FetchTransport } from './fetch-transport';
import { DynamicHeaderProvider } from '../types';

/**
* Create a logger provider for the current execution environment.
*
* @internal
*/
export function createLoggerProvider(endpointUrl: string): LoggerProvider {
export function createLoggerProvider(
endpointUrl: string,
dynamicHeaders: DynamicHeaderProvider[] = []
): LoggerProvider {
const resource = resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'firebase_telemetry_service'
});
if (endpointUrl.endsWith('/')) {
endpointUrl = endpointUrl.slice(0, -1);
}
const otlpEndpoint = `${endpointUrl}/api/v1/logs`;
const logExporter = new OTLPLogExporter(
{ url: otlpEndpoint },
dynamicHeaders
);

if (typeof process !== 'undefined' && process?.env?.NEXT_RUNTIME === 'edge') {
// We need a slightly custom implementation for the Edge Runtime, because it doesn't have access
// to many features available in Node.
const logExporter = new OTLPLogExporterEdge({ url: otlpEndpoint });
const provider = new LoggerProvider({
resource,
processors: [new BatchLogRecordProcessor(logExporter)],
logRecordLimits: {}
});
return provider;
} else {
const logExporter = new OTLPLogExporter({ url: otlpEndpoint });
return new LoggerProvider({
resource,
processors: [new BatchLogRecordProcessor(logExporter)]
});
}
return new LoggerProvider({
resource,
processors: [new BatchLogRecordProcessor(logExporter)],
logRecordLimits: {}
});
}

/** OTLP exporter that uses custom FetchTransport for use in the Edge Runtime. */
class OTLPLogExporterEdge
/** OTLP exporter that uses custom FetchTransport. */
class OTLPLogExporter
extends OTLPExporterBase<ReadableLogRecord[]>
implements LogRecordExporter
{
constructor(config: OTLPExporterConfigBase = {}) {
constructor(
config: OTLPExporterConfigBase = {},
dynamicHeaders: DynamicHeaderProvider[] = []
) {
super(
createOtlpNetworkExportDelegate(
{
Expand All @@ -79,11 +77,12 @@ class OTLPLogExporterEdge
compression: 'none'
},
JsonLogsSerializer,
new FetchTransportEdge({
new FetchTransport({
url: config.url!,
headers: () => ({
'Content-Type': 'application/json'
})
}),
dynamicHeaders
})
)
);
Expand Down
3 changes: 1 addition & 2 deletions packages/telemetry/src/register.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,8 @@ export function registerTelemetry(): void {
// getImmediate for FirebaseApp will always succeed
const app = container.getProvider('app').getImmediate();
const loggerProvider = createLoggerProvider(endpointUrl);
const appCheckProvider = container.getProvider('app-check-internal');

return new TelemetryService(app, loggerProvider, appCheckProvider);
return new TelemetryService(app, loggerProvider);
},
ComponentType.PUBLIC
).setMultipleInstances(true)
Expand Down
9 changes: 7 additions & 2 deletions packages/telemetry/src/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { TELEMETRY_TYPE } from './constants';
import { name, version } from '../package.json';
import { TelemetryService } from './service';
import { createLoggerProvider } from './logging/logger-provider';
import { AppCheckProvider } from './logging/appcheck-provider';

export function registerTelemetry(): void {
_registerComponent(
Expand All @@ -36,10 +37,14 @@ export function registerTelemetry(): void {

// getImmediate for FirebaseApp will always succeed
const app = container.getProvider('app').getImmediate();
const loggerProvider = createLoggerProvider(endpointUrl);
const appCheckProvider = container.getProvider('app-check-internal');
const dynamicHeaders = [new AppCheckProvider(appCheckProvider)];
const loggerProvider = createLoggerProvider(
endpointUrl,
dynamicHeaders
);

return new TelemetryService(app, loggerProvider, appCheckProvider);
return new TelemetryService(app, loggerProvider);
},
ComponentType.PUBLIC
).setMultipleInstances(true)
Expand Down
Loading
Loading