@salesforce/o11y-reporter
O11y Reporter
A lightweight telemetry reporting service for Salesforce extensions that enables sending metrics and events to Salesforce's observability platform.
Usage
The O11y Reporter service provides a simple way to send telemetry events and metrics to Salesforce's observability platform. Here's how to use it:
1. Initialize the Service
import { O11yService } from "@salesforce/o11y-reporter";
// Get the singleton instance
const o11yService = O11yService.getInstance(extensionName);
// Initialize with your extension name and upload endpoint (static endpoint only)
await o11yService.initialize(
"your-extension-name",
"https://your-upload-endpoint",
);
Dynamic endpoint (authenticated org): To send telemetry to the current org’s endpoint (with automatic fallback to the static endpoint if the connection is unavailable), pass an optional getConnection callback and, if needed, an options object with dynamicO11yUploadEndpointPath. If you omit the path, a default is used.
import { O11yService, type Connection } from "@salesforce/o11y-reporter";
const o11yService = O11yService.getInstance(extensionName);
await o11yService.initialize(
"your-extension-name",
"https://fallback-static-endpoint",
() => yourWorkspaceContext.getConnection(),
{ dynamicO11yUploadEndpointPath: "/your/telemetry/path" },
);
The Connection type is re-exported from @salesforce/core; depend on it when using getConnection.
Opt-in Falcon publishing (API key): Publish telemetry to the Falcon service instead of the org/static endpoint by passing a falcon option. Opt-in is keyed on the API key: with a valid key and a resolved endpoint, Falcon becomes the selected upload destination; a missing key preserves the existing behavior exactly. Supply only the pod base host — the library appends the fixed /ui-telemetry ingest path. The key is sent as the x-o11y-api-key header; never hardcode it (read it from config/env), and note a key is valid only against the environment (dev/prod) it was minted on.
import {
O11yService,
type FalconUploadOptions,
} from "@salesforce/o11y-reporter";
const o11yService = O11yService.getInstance(extensionName);
await o11yService.initialize(
"your-extension-name",
"https://fallback-static-endpoint",
() => yourWorkspaceContext.getConnection(),
{
falcon: {
apiKey: process.env.O11Y_FALCON_API_KEY!, // never hardcode
environment: "dev", // "dev" | "prod"; defaults to "dev"
endpoints: {
dev: "https://your-dev-pod-host",
prod: "https://your-prod-pod-host",
},
},
},
);
Local-headless fallback (zero network egress): When Falcon is opted in (key present) but no endpoint resolves, inject a headlessCollector to capture telemetry locally with no network upload. The consumer owns the collector package (e.g. @o11y/local-headless) and injects the instance; the library takes no runtime dependency on it.
import { LocalCollector } from "@o11y/local-headless";
await o11yService.initialize(
"your-extension-name",
"https://fallback-static-endpoint",
undefined,
{
falcon: { apiKey: process.env.O11Y_FALCON_API_KEY! }, // no endpoint -> headless
headlessCollector: new LocalCollector(/* ... */),
},
);
Multi-instance note: Multiple extensions in one process (e.g. the Salesforce Extension Pack sharing a single VS Code extension host) share one process-global telemetry buffer, because the underlying o11y client registers exactly one app per process. The upload destination is therefore claimed first-writer-wins: the first extension to initialize determines where all pooled events are published, and a later extension that configures a conflicting destination is logged as an error and follows the first owner's route. Configure a consistent Falcon/headless destination across co-hosted extensions.
Application identity — appName vs. per-consumer identity: Every event carries a per-consumer loggerName sourced from the extensionName you pass to initialize, so co-hosted extensions stay distinguishable even though they share one buffer — you do not need to configure anything for that. Optionally pass a process-level appName to label the CoreEnvelope for the whole process (e.g. "salesforce-extension-pack"); it is first-writer-wins (the first initialize sets it, a later different value is logged and ignored) and defaults to "o11y-reporter-extensions". appName is the shared process label, not the per-extension identity — that always rides on loggerName.
- Single-product process (CLI, ADK Agent, adk-mcp): one consumer is the process, so
appNamemay simply equal yourextensionName(or any product label). Setting it is enough on its own. - Co-hosted process (Salesforce Extension Pack in one VS Code extension host):
appNamecannot vary per extension (first-writer-wins across the shared buffer), so don't try to make it the per-extension identifier — rely onloggerName(=extensionName), which is stamped per event automatically.
Input validation (fails closed): extensionName is validated before any state is touched — it must be a non-blank string with no surrounding whitespace, no backtick, at most 80 characters, and must not be the registered app name ("o11y-reporter-extensions") or a reserved o11y instrumentation name (IdleDetector, Network, WebVitals). A supplied appName must be non-blank. An invalid value is logged and telemetry is left uninitialized; it never throws, so a bad config cannot disrupt the host application. Valid names are preserved exactly (no trimming, prefixing, or hashing) so extensionName === loggerName on the wire.
await o11yService.initialize(
"salesforcedx-vscode-apex", // -> per-event loggerName
"https://your-endpoint",
undefined,
{ appName: "salesforce-extension-pack" }, // -> process-level CoreEnvelope appName
);
2. Send Telemetry Events
Using Default Schema
// Send a telemetry event with properties (uses default sf_a4dInstrumentation schema)
o11yService.logEvent({
name: "extension/eventName",
properties: {
// Add your custom properties here
customProperty: "value",
},
measurements: {
// Optional measurements
duration: 100,
},
});
// Send an exception event
o11yService.logEvent({
exception: new Error("Error message"),
properties: {
// Add your custom properties here
errorType: "RuntimeError",
},
measurements: {
// Optional measurements
errorCount: 1,
},
});
Using Custom Schema
The service supports consumer-provided O11y schemas. This allows you to use custom schemas from the o11y_schema package instead of the default sf_a4dInstrumentation schema.
Step 1: Add o11y_schema to your package.json:
{
"dependencies": {
"o11y_schema": "^256.154.0"
}
}
Step 2: Import and use the schema with logEventWithSchema(properties, schema) (exactly two parameters):
import { O11yService } from "@salesforce/o11y-reporter";
// Import the schema object from o11y_schema
// @ts-expect-error - o11y_schema package doesn't provide TypeScript declarations
import { a4dInstrumentationSchema } from "o11y_schema/sf_a4dInstrumentation";
const o11yService = O11yService.getInstance("your-extension");
await o11yService.initialize("your-extension", "https://your-endpoint");
// Two parameters only: (properties, schema). Shape of properties depends on your schema.
o11yService.logEventWithSchema(
{ customProperty: "value" },
a4dInstrumentationSchema,
);
// You can also use different schemas for different events
// @ts-expect-error - o11y_schema package doesn't provide TypeScript declarations
import { anotherSchema } from "o11y_schema/another_schema";
o11yService.logEventWithSchema({ foo: "bar" }, anotherSchema);
Note: logEventWithSchema(properties, schema) takes exactly two parameters: the event properties object and the schema. You must provide a valid schema object (e.g. Record<string, unknown>). If you don't need a custom schema, use logEvent(properties) which uses the default schema.
Behavior: Events sent with logEventWithSchema(properties, schema) are encoded using the schema you pass (e.g. PdpEvent, a4dInstrumentationSchema). They are not wrapped in the default A4dInstrumentation schema; the payload uses your schema's format directly (e.g. sf.pdp.PdpEvent in the binary payload).
Note: The o11y_schema package doesn't provide TypeScript declarations. You may need to add @ts-expect-error comments or configure your TypeScript with skipLibCheck: true in tsconfig.json.
With automatic batching enabled, events are automatically uploaded based on threshold (50KB) or periodic flush (30 seconds). You can also manually flush if needed:
await o11yService.forceFlush();
3. Enable Automatic Batching
The service supports automatic batching of events for efficient telemetry collection. Events are automatically buffered and uploaded based on size threshold or periodic flush intervals.
// Enable automatic batching with default settings (30-second flush interval)
const cleanup = o11yService.enableAutoBatching();
// Or customize the batching behavior
const cleanup = o11yService.enableAutoBatching({
flushInterval: 30_000, // 30 seconds (default)
enableShutdownHook: true, // Enable shutdown hooks (default: true)
enableBeforeExitHook: true, // Enable beforeExit hook (default: true)
});
// Later, if you need to stop batching
cleanup();
Batching Behavior:
- Events are buffered in memory until upload conditions are met
- Threshold-based upload: Events are uploaded when buffer size reaches 50KB
- Periodic flush: Events are automatically flushed every 30 seconds (configurable)
- Shutdown hooks: Events are automatically flushed on application shutdown (SIGINT, SIGTERM, beforeExit)
Benefits:
- Reduces network overhead by batching multiple events
- Improves performance by avoiding per-event uploads
- Ensures events are not lost on application shutdown
4. Manual Flush
You can manually flush buffered events at any time:
// Force an immediate flush of all buffered events
await o11yService.forceFlush();
// Or use the upload method (alias for forceFlush when batching is enabled)
await o11yService.upload();
5. Check Batch Status
Monitor the current batch status:
const status = o11yService.getBatchStatus();
console.log(`Buffer size: ${status.estimatedByteSize} bytes`);
console.log(`Has data: ${status.hasData}`);
console.log(`Over threshold: ${status.isOverThreshold}`);
Configuration
The service can be configured with the following options:
o11yUploadEndpoint: The endpoint URL for uploading telemetry data
Batching Options
When enabling automatic batching, you can configure the following options:
interface BatchingOptions {
/** Periodic flush interval in milliseconds (default: 30000) */
flushInterval?: number;
/** Buffer size threshold in bytes before triggering upload (default: 50000 = 50KB) */
thresholdBytes?: number;
/** Threshold check interval in milliseconds (default: 2000) */
checkInterval?: number;
/** Enable shutdown hooks (default: true) */
enableShutdownHook?: boolean;
/** Enable beforeExit hook (default: true). Note: beforeExit won't fire for STDIO servers where stdin stays open */
enableBeforeExitHook?: boolean;
}
Configuration Examples:
// Default configuration (30-second flush, shutdown hooks enabled)
o11yService.enableAutoBatching();
// Custom flush interval (60 seconds)
o11yService.enableAutoBatching({ flushInterval: 60_000 });
// Disable shutdown hooks (not recommended)
o11yService.enableAutoBatching({ enableShutdownHook: false });
// Disable beforeExit hook (useful for STDIO servers)
o11yService.enableAutoBatching({ enableBeforeExitHook: false });
Best Practices
Initialize Early: Initialize the service as early as possible in your extension's lifecycle.
Error Handling: Always wrap telemetry calls in try-catch blocks to prevent them from affecting your main application flow.
Property Naming: Use consistent property names and avoid sending sensitive information.
Enable Automatic Batching: Use
enableAutoBatching()to automatically batch and upload events efficiently. This is recommended for most use cases.Manual Flush for Critical Events: For critical events that need immediate upload, call
forceFlush()after logging the event.
Example Implementation
Here's a complete example of how to use the O11y Reporter in your extension:
import { O11yService } from "@salesforce/o11y-reporter";
class YourExtension {
private o11yService: O11yService;
constructor() {
this.o11yService = O11yService.getInstance(extensionName);
}
async initialize() {
await this.o11yService.initialize(
"your-extension",
"https://your-endpoint",
);
// Enable automatic batching for efficient telemetry collection
this.o11yService.enableAutoBatching({
flushInterval: 30_000, // 30 seconds
enableShutdownHook: true, // Flush on shutdown
});
}
async trackUserAction(actionName: string, properties: Record<string, any>) {
try {
// Using default schema
this.o11yService.logEvent({
name: `user/${actionName}`,
properties: {
...properties,
timestamp: new Date().toISOString(),
},
});
// With batching enabled, events are automatically uploaded
// For critical events, you can force an immediate flush:
await this.o11yService.forceFlush();
} catch (error) {
// Log error but don't throw
console.error("Failed to send telemetry:", error);
}
}
async trackUserActionWithSchema(
actionName: string,
properties: Record<string, any>,
schema: Record<string, unknown>,
) {
try {
// Two parameters: caller's properties (conform to schema) and schema.
this.o11yService.logEventWithSchema(properties, schema);
// With batching enabled, events are automatically uploaded
await this.o11yService.forceFlush();
} catch (error) {
console.error("Failed to send telemetry:", error);
}
}
async trackError(error: Error, context: Record<string, any>) {
try {
this.o11yService.logEvent({
exception: error,
properties: {
...context,
timestamp: new Date().toISOString(),
},
});
await this.o11yService.upload();
} catch (err) {
console.error("Failed to send error telemetry:", err);
}
}
}
License
This project is licensed under the Terms of Use. See the LICENSE.txt file for details.