Skip to content

feat(crashlytics): refactor to support OpenTelemetry SDK integration, log batching, and custom options - #10232

Draft
bryanatkinson wants to merge 10 commits into
crashlytics2from
crashlytics-otel-sdk-refactor
Draft

feat(crashlytics): refactor to support OpenTelemetry SDK integration, log batching, and custom options#10232
bryanatkinson wants to merge 10 commits into
crashlytics2from
crashlytics-otel-sdk-refactor

Conversation

@bryanatkinson

Copy link
Copy Markdown
Contributor

Discussion

  • Related Issue: N/A (Refactoring and feature enhancement for Crashlytics OpenTelemetry integration)

Description

Refactors @firebase/crashlytics to support direct OpenTelemetry (OTel) SDK integration, enhanced log batching/flushing capabilities, and additional developer configuration options.

Key changes:

  • OpenTelemetry SDK Integration: Added resolveLoggerProvider allowing zero-config lightweight logging via MicroOtelLoggerProvider while supporting full OTel SDK LoggerProvider with custom processors, exporters, and resources.
  • Power User APIs & Options: Added support for loggerProvider, logger, extraProcessors, extraExporters, useGlobalLoggerProvider, registerGlobalLoggerProvider, and resource in CrashlyticsOptions. Exposed getOtelLoggerProvider and getOtelLogger helper functions.
  • Log Batching & Attribute Processing: Improved log attribute propagation with FirebaseAttributesProcessor and streamlined log batching/flushing.
  • Instrumentation Support: Added support for auto-instrumentation options in CrashlyticsOptions.

Testing

  • Executed unit test suites for both Node.js (test:node) and Browser (test:browser via Karma).
  • Added comprehensive unit tests in src/api.test.ts covering OpenTelemetry power user APIs, custom logger providers, custom loggers, and extra processors.
  • Verified all 55 unit tests pass cleanly.

API Changes

Added the following non-breaking public API additions to @firebase/crashlytics:

  • Extended CrashlyticsOptions with optional fields: loggerProvider, logger, extraProcessors, extraExporters, useGlobalLoggerProvider, registerGlobalLoggerProvider, resource, and instrumentation.
  • Exported helper functions getOtelLoggerProvider(crashlytics: Crashlytics) and getOtelLogger(crashlytics: Crashlytics).

Adds the `instrumentation` flag to CrashlyticsOptions to allow web developers to easily enable full OpenTelemetry browser auto-instrumentation and global logger provider registration.

TAG=agy
CONV=82994c94-bc1d-4502-9a3d-b6fe1d4d4970
@changeset-bot

changeset-bot Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d9d4ddd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces OpenTelemetry Power User APIs and a lightweight MicroOtelLoggerProvider to Firebase Crashlytics, allowing for custom loggers, global providers, and extra processors. Feedback on these changes focuses on enhancing the robustness of the lightweight provider, including initializing undefined log attributes, supporting nested objects via kvListValue, correctly parsing Date and HrTime timestamps, and checking response.ok during fetch retries. Additionally, it is recommended to align the exposed LoggerProvider types with the @opentelemetry/api-logs package to support custom API providers cleanly.

Comment on lines +35 to +49
if (logRecord.attributes) {
for (const [key, value] of Object.entries(dynamicAttributes)) {
if (value !== undefined && logRecord.attributes[key] === undefined) {
logRecord.attributes[key] = value as AttributeValue;
}
}

const cachedIid = this.attributesStore.getCachedInstallationId();
if (
cachedIid &&
logRecord.attributes[ATTR_KEY_INSTALLATION_ID] === undefined
) {
logRecord.attributes[ATTR_KEY_INSTALLATION_ID] = cachedIid;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

If logRecord.attributes is undefined, the processor currently skips adding any dynamic Firebase attributes (such as session ID, app version, and installation ID). We should initialize logRecord.attributes to an empty object if it is undefined to ensure that all logs are correctly enriched with Firebase metadata.

    if (!logRecord.attributes) {
      logRecord.attributes = {};
    }

    for (const [key, value] of Object.entries(dynamicAttributes)) {
      if (value !== undefined && logRecord.attributes[key] === undefined) {
        logRecord.attributes[key] = value as AttributeValue;
      }
    }

    const cachedIid = this.attributesStore.getCachedInstallationId();
    if (
      cachedIid &&
      logRecord.attributes[ATTR_KEY_INSTALLATION_ID] === undefined
    ) {
      logRecord.attributes[ATTR_KEY_INSTALLATION_ID] = cachedIid;
    }

Comment on lines +27 to +33
export interface OtlpAttributeValue {
stringValue?: string;
boolValue?: boolean;
intValue?: number;
doubleValue?: number;
arrayValue?: { values: OtlpAttributeValue[] };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Add kvListValue to the OtlpAttributeValue interface to support nested maps/objects in custom attributes.

Suggested change
export interface OtlpAttributeValue {
stringValue?: string;
boolValue?: boolean;
intValue?: number;
doubleValue?: number;
arrayValue?: { values: OtlpAttributeValue[] };
}
export interface OtlpAttributeValue {
stringValue?: string;
boolValue?: boolean;
intValue?: number;
doubleValue?: number;
arrayValue?: { values: OtlpAttributeValue[] };
kvListValue?: { values: OtlpKeyValue[] };
}

Comment on lines +40 to +58
function formatOtlpAttributeValue(val: unknown): OtlpAttributeValue {
if (typeof val === 'string') {
return { stringValue: val };
}
if (typeof val === 'boolean') {
return { boolValue: val };
}
if (typeof val === 'number') {
return Number.isInteger(val) ? { intValue: val } : { doubleValue: val };
}
if (Array.isArray(val)) {
return {
arrayValue: {
values: val.map(v => formatOtlpAttributeValue(v))
}
};
}
return { stringValue: String(val) };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In OpenTelemetry, custom attributes can be nested maps/objects. Currently, formatOtlpAttributeValue falls back to String(val) for objects, which serializes them as "[object Object]" and causes data loss. We should support kvListValue to correctly serialize nested objects.

function formatOtlpAttributeValue(val: unknown): OtlpAttributeValue {
  if (typeof val === 'string') {
    return { stringValue: val };
  }
  if (typeof val === 'boolean') {
    return { boolValue: val };
  }
  if (typeof val === 'number') {
    return Number.isInteger(val) ? { intValue: val } : { doubleValue: val };
  }
  if (Array.isArray(val)) {
    return {
      arrayValue: {
        values: val.map(v => formatOtlpAttributeValue(v))
      }
    };
  }
  if (typeof val === 'object' && val !== null) {
    return {
      kvListValue: {
        values: formatOtlpAttributes(val as Record<string, unknown>)
      }
    };
  }
  return { stringValue: String(val) };
}

Comment on lines +144 to +150
const timeNano = logRecord.timestamp
? String(
typeof logRecord.timestamp === 'number'
? logRecord.timestamp * 1000000
: Date.now() * 1000000
)
: String(Date.now() * 1000000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The logRecord.timestamp can be a Date object or an HrTime array (e.g., from auto-instrumentation libraries). Currently, the check typeof logRecord.timestamp === 'number' fails for these types, causing the logger to fall back to Date.now(), which discards the actual timestamp of the log. We should handle all TimeInput types correctly.

      let timeNano: string;
      if (logRecord.timestamp instanceof Date) {
        timeNano = String(logRecord.timestamp.getTime() * 1000000);
      } else if (typeof logRecord.timestamp === 'number') {
        timeNano = String(logRecord.timestamp * 1000000);
      } else if (Array.isArray(logRecord.timestamp)) {
        timeNano = String(logRecord.timestamp[0] * 1000000000 + logRecord.timestamp[1]);
      } else {
        timeNano = String(Date.now() * 1000000);
      }

Comment on lines +218 to +231
const sendRequest = async (retriesLeft = 1): Promise<void> => {
try {
await fetch(this.endpointUrl, {
method: 'POST',
headers,
body: payloadStr
});
} catch (err) {
if (retriesLeft > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
return sendRequest(retriesLeft - 1);
}
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The fetch API does not throw an error on non-ok HTTP status codes (like 5xx or 429). To ensure robust log delivery, we should check response.ok and retry on transient failures.

Suggested change
const sendRequest = async (retriesLeft = 1): Promise<void> => {
try {
await fetch(this.endpointUrl, {
method: 'POST',
headers,
body: payloadStr
});
} catch (err) {
if (retriesLeft > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
return sendRequest(retriesLeft - 1);
}
}
};
const sendRequest = async (retriesLeft = 1): Promise<void> => {
try {
const response = await fetch(this.endpointUrl, {
method: 'POST',
headers,
body: payloadStr
});
if (!response.ok && retriesLeft > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
return sendRequest(retriesLeft - 1);
}
} catch (err) {
if (retriesLeft > 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
return sendRequest(retriesLeft - 1);
}
}
};

Comment on lines +23 to +24
import { AnyValueMap, SeverityNumber, Logger } from '@opentelemetry/api-logs';
import { LoggerProvider } from '@opentelemetry/sdk-logs';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since Crashlytics now supports custom API LoggerProviders (such as MicroOtelLoggerProvider), getOtelLoggerProvider should return the API LoggerProvider from @opentelemetry/api-logs instead of the SDK LoggerProvider from @opentelemetry/sdk-logs. This avoids type lies and potential runtime errors for consumers.

Suggested change
import { AnyValueMap, SeverityNumber, Logger } from '@opentelemetry/api-logs';
import { LoggerProvider } from '@opentelemetry/sdk-logs';
import { AnyValueMap, SeverityNumber, Logger, LoggerProvider } from '@opentelemetry/api-logs';

Comment on lines 18 to +19
import { LoggerProvider } from '@opentelemetry/sdk-logs';
import { Logger } from '@opentelemetry/api-logs';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Align the loggerProvider type in CrashlyticsInternal with @opentelemetry/api-logs's LoggerProvider to support lightweight or custom API providers.

Suggested change
import { LoggerProvider } from '@opentelemetry/sdk-logs';
import { Logger } from '@opentelemetry/api-logs';
import { LoggerProvider, Logger } from '@opentelemetry/api-logs';

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant