diff --git a/CHANGELOG.md b/CHANGELOG.md
index 363f3be5..4a69a089 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,6 +53,7 @@ Sentry.init({
### Features
+- Add experimental Metric support for Web and iOS ([#1055](https://github.com/getsentry/sentry-capacitor/pull/1055))
- Add Fallback to JavaScript SDK when Native SDK fails to initialize ([#1043](https://github.com/getsentry/sentry-capacitor/pull/1043))
- Add spotlight integration `spotlightIntegration`. ([#1039](https://github.com/getsentry/sentry-capacitor/pull/1039))
diff --git a/example/ionic-angular-v6/src/app/tab1/tab1.page.html b/example/ionic-angular-v6/src/app/tab1/tab1.page.html
index c926bf90..c5271097 100644
--- a/example/ionic-angular-v6/src/app/tab1/tab1.page.html
+++ b/example/ionic-angular-v6/src/app/tab1/tab1.page.html
@@ -72,5 +72,9 @@
Clear Test Context
Clear Test Context
+
diff --git a/example/ionic-angular-v6/src/app/tab1/tab1.page.ts b/example/ionic-angular-v6/src/app/tab1/tab1.page.ts
index 1141ad85..469cac3f 100644
--- a/example/ionic-angular-v6/src/app/tab1/tab1.page.ts
+++ b/example/ionic-angular-v6/src/app/tab1/tab1.page.ts
@@ -138,4 +138,11 @@ export class Tab1Page {
public clearTestContext(): void {
Sentry.setContext('TEST-CONTEXT', null);
}
+
+ public createMetric(): void {
+ // Create a metric using Sentry metrics API
+ Sentry.metrics.count('test.metric.counter', 1,
+ { attributes: { from_test_app: true } },
+ );
+ }
}
diff --git a/example/ionic-angular-v7/src/app/app.module.ts b/example/ionic-angular-v7/src/app/app.module.ts
index 8aeb2593..37ef33d6 100644
--- a/example/ionic-angular-v7/src/app/app.module.ts
+++ b/example/ionic-angular-v7/src/app/app.module.ts
@@ -23,6 +23,12 @@ Sentry.init(
// Whether SDK should be enabled or not
enabled: true,
// Use the tracing integration to see traces and add performance monitoring
+ _experiments: {
+ enableMetrics: true,
+ beforeSendMetric: (metric) => {
+ return metric;
+ },
+ },
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
diff --git a/example/ionic-angular-v7/src/app/tab1/tab1.page.html b/example/ionic-angular-v7/src/app/tab1/tab1.page.html
index c926bf90..c5271097 100644
--- a/example/ionic-angular-v7/src/app/tab1/tab1.page.html
+++ b/example/ionic-angular-v7/src/app/tab1/tab1.page.html
@@ -72,5 +72,9 @@
Clear Test Context
Clear Test Context
+
diff --git a/example/ionic-angular-v7/src/app/tab1/tab1.page.ts b/example/ionic-angular-v7/src/app/tab1/tab1.page.ts
index 1141ad85..09ecc1a6 100644
--- a/example/ionic-angular-v7/src/app/tab1/tab1.page.ts
+++ b/example/ionic-angular-v7/src/app/tab1/tab1.page.ts
@@ -138,4 +138,11 @@ export class Tab1Page {
public clearTestContext(): void {
Sentry.setContext('TEST-CONTEXT', null);
}
+
+ public createMetric(): void {
+ // Create a metric using Sentry metrics API
+ Sentry.metrics.count('test.metric.counter', 1,
+ { attributes: { from_test_app: true } },
+ );
+ }
}
diff --git a/example/ionic-vue3/src/views/HomePage.vue b/example/ionic-vue3/src/views/HomePage.vue
index 2fe86bd6..9060004b 100644
--- a/example/ionic-vue3/src/views/HomePage.vue
+++ b/example/ionic-vue3/src/views/HomePage.vue
@@ -1,43 +1,64 @@
-
-
- Sentry Capacitor Vue
-
-
-
- Create Message
- Handled Error
- Crash
-
+
+
+ Sentry Capacitor Vue
+
+
+
+ Create Message
+ Handled Error
+ Crash
+ Create Metric
+
diff --git a/src/index.ts b/src/index.ts
index 16d6c83e..f9bc7fb5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,7 @@ export type {
Stacktrace,
Thread,
User,
+ Metric
} from '@sentry/core';
export type { Scope } from '@sentry/core';
@@ -49,7 +50,7 @@ export {
startIdleSpan,
} from '@sentry/core';
-export { replayIntegration, browserTracingIntegration, registerSpanErrorInstrumentation, logger } from '@sentry/browser';
+export { metrics, replayIntegration, browserTracingIntegration, registerSpanErrorInstrumentation, logger } from '@sentry/browser';
export { pauseAppHangTracking, resumeAppHangTracking } from './wrapper';
diff --git a/src/options.ts b/src/options.ts
index 595c71d8..2f074c4c 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -1,5 +1,5 @@
import type { BrowserOptions, makeFetchTransport } from '@sentry/browser';
-import type { ClientOptions } from '@sentry/core';
+import type { ClientOptions, Metric } from '@sentry/core';
import type { NuxtOptions, VueOptions } from './siblingOptions';
// Direct reference of BrowserTransportOptions is not compatible with strict builds of latest versions of Typescript 5.
@@ -95,13 +95,44 @@ export interface BaseCapacitorOptions {
*/
nuxtClientOptions?: NuxtOptions;
};
+
+ /**
+ * Options which are in beta, or otherwise not guaranteed to be stable.
+ */
+ _experiments?: {
+
+ /**
+ * If metrics support should be enabled.
+ *
+ * @default false
+ * @experimental
+ * @platforms web and iOS only. On the future it will be enabled on Android.
+ */
+ enableMetrics?: boolean;
+
+ /**
+ * An event-processing callback for metrics, guaranteed to be invoked after all other metric
+ * processors. This allows a metric to be modified or dropped before it's sent.
+ *
+ * Note that you must return a valid metric from this callback. If you do not wish to modify the metric, simply return
+ * it at the end. Returning `null` will cause the metric to be dropped.
+ *
+ * @default undefined
+ * @experimental
+ * @platforms web and iOS only. On the future it will be enabled on Android.
+ *
+ * @param metric The metric generated by the SDK.
+ * @returns A new metric that will be sent | null.
+ */
+ beforeSendMetric?: (metric: Metric) => Metric | null;
+ };
}
/**
* Configuration options for the Sentry Capacitor SDK.
*/
export interface CapacitorOptions
- extends Omit,
+ extends Omit,
BaseCapacitorOptions { }
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@@ -109,5 +140,5 @@ export interface CapacitorTransportOptions extends BrowserTransportOptions { }
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface CapacitorClientOptions
- extends ClientOptions,
+ extends Omit, '_experiments' | 'enableMetrics'>,
BaseCapacitorOptions { }
diff --git a/src/sdk.ts b/src/sdk.ts
index 03774c69..cd1475dd 100644
--- a/src/sdk.ts
+++ b/src/sdk.ts
@@ -80,6 +80,8 @@ finalOptions.transport = passedOptions.transport || makeNativeTransport;
...finalOptions,
autoSessionTracking:
NATIVE.platform === 'web' && finalOptions.enableAutoSessionTracking,
+ enableMetrics: finalOptions._experiments?.enableMetrics,
+ beforeSendMetric: finalOptions._experiments?.beforeSendMetric,
} as BrowserOptions;
const mobileOptions = {
diff --git a/src/wrapper.ts b/src/wrapper.ts
index 18580729..997aea57 100644
--- a/src/wrapper.ts
+++ b/src/wrapper.ts
@@ -59,7 +59,7 @@ export const NATIVE = {
: 'application/octet-stream';
bytesPayload = [...itemPayload];
} else {
- bytesContentType = 'application/vnd.sentry.items.log+json';
+ bytesContentType = typeof itemHeader.content_type === 'string' ? itemHeader.content_type : 'application/json';
bytesPayload = utf8ToBytes(JSON.stringify(itemPayload));
}
diff --git a/test/helper/envelopeHelper.ts b/test/helper/envelopeHelper.ts
new file mode 100644
index 00000000..2ba15373
--- /dev/null
+++ b/test/helper/envelopeHelper.ts
@@ -0,0 +1,122 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import type { DsnComponents, LogEnvelope, MetricEnvelope, SdkMetadata, SerializedLog, SerializedMetric } from '@sentry/core';
+import { createEnvelope, dsnToString } from '@sentry/core';
+import type { LogContainerItem, MetricContainerItem } from '@sentry/core/build/types/types-hoist/envelope';
+
+
+/**
+ * Based on packages/core/src/metrics/envelope.ts
+ * Creates a metric container envelope item for a list of metrics.
+ *
+ * @param items - The metrics to include in the envelope.
+ * @returns The created metric container envelope item.
+ */
+export function createMetricContainerEnvelopeItem(items: Array): MetricContainerItem {
+ return [
+ {
+ type: 'trace_metric',
+ item_count: items.length,
+ content_type: 'application/vnd.sentry.items.trace-metric+json',
+ } as MetricContainerItem[0],
+ {
+ items,
+ },
+ ];
+}
+
+
+/**
+ * Based on getsentry/sentry-javascript/packages/core/src/logs/envelope.ts
+ * Creates a log container envelope item for a list of logs.
+ *
+ * @param items - The logs to include in the envelope.
+ * @returns The created log container envelope item.
+ */
+export function createLogContainerEnvelopeItem(items: Array): LogContainerItem {
+ return [
+ {
+ type: 'log',
+ item_count: items.length,
+ content_type: 'application/vnd.sentry.items.log+json',
+ },
+ {
+ items,
+ },
+ ];
+}
+
+/**
+ * Based on getsentry/sentry-javascript/packages/core/src/logs/envelope.ts
+ * Creates an envelope for a list of logs.
+ *
+ * Logs from multiple traces can be included in the same envelope.
+ *
+ * @param logs - The logs to include in the envelope.
+ * @param metadata - The metadata to include in the envelope.
+ * @param tunnel - The tunnel to include in the envelope.
+ * @param dsn - The DSN to include in the envelope.
+ * @returns The created envelope.
+ */
+export function createLogEnvelopeHelper(logs: Array,
+ metadata?: SdkMetadata,
+ tunnel?: string,
+ dsn?: DsnComponents,
+): LogEnvelope {
+ const headers: LogEnvelope[0] = {};
+
+ if (metadata?.sdk) {
+ headers.sdk = {
+ name: metadata.sdk.name,
+ version: metadata.sdk.version,
+ };
+ }
+
+ if (!!tunnel && !!dsn) {
+ headers.dsn = dsnToString(dsn);
+ }
+
+ return createEnvelope(headers, [createLogContainerEnvelopeItem(logs)]);
+}
+
+/**
+ * Based on packages/core/src/metrics/envelope.ts
+ * Creates an envelope for a list of metrics.
+ *
+ * Metrics from multiple traces can be included in the same envelope.
+ *
+ * @param metrics - The metrics to include in the envelope.
+ * @param metadata - The metadata to include in the envelope.
+ * @param tunnel - The tunnel to include in the envelope.
+ * @param dsn - The DSN to include in the envelope.
+ * @returns The created envelope.
+ */
+export function createMetricEnvelopeHelper(metrics: Array,
+ metadata?: SdkMetadata,
+ tunnel?: string,
+ dsn?: DsnComponents): MetricEnvelope {
+ const headers: MetricEnvelope[0] = {};
+
+ if (metadata?.sdk) {
+ headers.sdk = {
+ name: metadata.sdk.name,
+ version: metadata.sdk.version,
+ };
+ }
+
+ if (!!tunnel && !!dsn) {
+ headers.dsn = dsnToString(dsn);
+ }
+
+ return createEnvelope(headers, [createMetricContainerEnvelopeItem(metrics)]);
+}
+
+
+export function base64EnvelopeToString(envelope: string): string | undefined {
+ if (!envelope) {
+ return undefined;
+ }
+
+ const decoded = Buffer.from(envelope, 'base64').toString('utf-8');
+ return decoded;
+}
+
diff --git a/test/sdk.test.ts b/test/sdk.test.ts
index 6756969e..bf14bc47 100644
--- a/test/sdk.test.ts
+++ b/test/sdk.test.ts
@@ -143,6 +143,34 @@ describe('SDK Init', () => {
});
});
+ test('passes metrics experiments to browser options', () => {
+ NATIVE.platform = 'web';
+ const mockOriginalInit = jest.fn();
+ const beforeSendMetric = jest.fn(metric => metric);
+
+ init({
+ dsn: 'test-dsn',
+ enabled: true,
+ _experiments: {
+ enableMetrics: true,
+ beforeSendMetric,
+ },
+ }, mockOriginalInit);
+
+ // Wait for async operations
+ return new Promise(resolve => {
+ setTimeout(() => {
+ expect(mockOriginalInit).toHaveBeenCalled();
+ const browserOptions = mockOriginalInit.mock.calls[0][0];
+
+ expect(browserOptions.enableMetrics).toBe(true);
+ expect(browserOptions.beforeSendMetric).toBe(beforeSendMetric);
+
+ resolve();
+ }, 10);
+ });
+ });
+
test('RewriteFrames to be added by default', async () => {
NATIVE.platform = 'web';
init({ enabled: true }, async (capacitorOptions: CapacitorOptions) => {
diff --git a/test/wrapper.test.ts b/test/wrapper.test.ts
index 2f7a1acd..557eea67 100644
--- a/test/wrapper.test.ts
+++ b/test/wrapper.test.ts
@@ -2,6 +2,7 @@
import type { Envelope, EventEnvelope, EventItem, SeverityLevel, TransportMakeRequestResponse } from '@sentry/core';
import { createEnvelope, debug, dropUndefinedKeys } from '@sentry/core';
import { utf8ToBytes } from '../src/vendor';
+import { base64EnvelopeToString, createLogEnvelopeHelper, createMetricEnvelopeHelper } from './helper/envelopeHelper';
let getStringBytesLengthValue = 1;
@@ -182,7 +183,7 @@ describe('Tests Native Wrapper', () => {
});
const expectedItem = JSON.stringify({
type: 'event',
- content_type: 'application/vnd.sentry.items.log+json',
+ content_type: 'application/json',
length: expectedNativeLength,
});
const expectedPayload = JSON.stringify({
@@ -282,7 +283,120 @@ describe('Tests Native Wrapper', () => {
NATIVE.enableNative = true;
const result = await NATIVE.sendEnvelope(env);
expect(result).toMatchObject(expectedReturn);
- })
+ });
+
+ test('uses correct content type for metrics with content_type set', async () => {
+ // Create a SerializedMetric array as expected by createMetricEnvelope
+ const serializedMetrics = [
+ {
+ timestamp: 1765200319.505,
+ name: 'test.metric.counter',
+ value: 1,
+ type: 'counter' as const,
+ unit: 'none',
+ trace_id: '',
+ },
+ ];
+
+ // Use createMetricEnvelopeHelper to create a properly formatted metric envelope
+ const env = createMetricEnvelopeHelper(serializedMetrics);
+
+ await NATIVE.sendEnvelope(env);
+
+ expect(SentryCapacitor.captureEnvelope).toHaveBeenCalled();
+ const base64Envelope = (SentryCapacitor.captureEnvelope as jest.Mock).mock.calls[0]?.[0]?.envelope;
+ expect(base64Envelope).toBeDefined();
+ expect(base64EnvelopeToString(base64Envelope)).toEqual(
+`{}
+{"type":"trace_metric","item_count":1,"content_type":"application/vnd.sentry.items.trace-metric+json","length":124}
+{"items":[{"timestamp":1765200319.505,"name":"test.metric.counter","value":1,"type":"counter","unit":"none","trace_id":""}]}
+`);
+ });
+
+ test('uses correct content type for logs with content_type set', async () => {
+ // Create a SerializedLog array as expected by createLogEnvelope
+ const serializedLogs = [
+ {
+ timestamp: 1765200551.692,
+ level: 'info' as const,
+ body: 'test log message',
+ severity_number: 9,
+ attributes: {},
+ },
+ ];
+
+ // Use createLogEnvelopeHelper to create a properly formatted log envelope
+ const env = createLogEnvelopeHelper(serializedLogs);
+
+ await NATIVE.sendEnvelope(env);
+
+ expect(SentryCapacitor.captureEnvelope).toHaveBeenCalled();
+ const base64Envelope = (SentryCapacitor.captureEnvelope as jest.Mock).mock.calls[0]?.[0]?.envelope;
+ expect(base64Envelope).toBeDefined();
+ expect(base64EnvelopeToString(base64Envelope)).toEqual(
+`{}
+{"type":"log","item_count":1,"content_type":"application/vnd.sentry.items.log+json","length":117}
+{"items":[{"timestamp":1765200551.692,"level":"info","body":"test log message","severity_number":9,"attributes":{}}]}
+`);
+ });
+
+ test('respects existing content_type in item header', async () => {
+ const expectedNativeLength = 50;
+ getStringBytesLengthValue = expectedNativeLength;
+
+ const customItem = {
+ custom: 'data',
+ };
+
+ // Test with custom content_type - using any to bypass strict typing for test
+ const env = [
+ { event_id: 'custom0', sent_at: '123' },
+ [[{ type: 'log', content_type: 'application/vnd.sentry.items.custom+json' }, customItem]],
+ ] as any as Envelope;
+
+ await NATIVE.sendEnvelope(env);
+
+ expect(SentryCapacitor.captureEnvelope).toHaveBeenCalled();
+ const base64Envelope = (SentryCapacitor.captureEnvelope as jest.Mock).mock.calls[0]?.[0]?.envelope;
+ expect(base64Envelope).toBeDefined();
+ expect(base64EnvelopeToString(base64Envelope)).toEqual(
+`{"event_id":"custom0","sent_at":"123"}
+{"type":"log","content_type":"application/vnd.sentry.items.custom+json","length":17}
+{"custom":"data"}
+`);
+ });
+
+ test('defaults to application/json when content_type is not set', async () => {
+ const expectedNativeLength = 15;
+ getStringBytesLengthValue = expectedNativeLength;
+
+ const itemWithoutContentType = {
+ some: 'data',
+ };
+
+ const expectedHeader = JSON.stringify({
+ event_id: 'default0',
+ sent_at: '123'
+ });
+ const expectedItem = JSON.stringify({
+ type: 'event',
+ content_type: 'application/json',
+ length: expectedNativeLength,
+ });
+ const expectedPayload = JSON.stringify(itemWithoutContentType);
+
+ const expectedEnvelope = `${expectedHeader}\n${expectedItem}\n${expectedPayload}\n`;
+
+ // Test with item that doesn't have content_type set - should default to application/json
+ const env = [
+ { event_id: 'default0', sent_at: '123' },
+ [[{ type: 'event' }, itemWithoutContentType]],
+ ] as any as Envelope;
+
+ await NATIVE.sendEnvelope(env);
+
+ expect(SentryCapacitor.captureEnvelope).toHaveBeenCalledWith({ envelope: base64StringFromByteArray(utf8ToBytes(expectedEnvelope)) });
+ });
});
// TODO add this in when fetchRelease method is in progress