4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
4 changes: 2 additions & 2 deletions .size-limit.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
limit: '75.2 KB',
limit: '75.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags',
Expand DownExpand Up@@ -79,7 +79,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'),
gzip: true,
limit: '80 KB',
limit: '80.5 KB',
},
{
name: '@sentry/browser (incl. Tracing, Replay, Feedback)',
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
addAutoIpAddressToUser,
applySdkMetadata,
getSDKSource,
_INTERNAL_flushLogsBuffer,
} from '@sentry/core';
import { eventFromException, eventFromMessage } from './eventbuilder';
import { WINDOW } from './helpers';
Expand DownExpand Up@@ -85,6 +86,9 @@ export class BrowserClient extends Client<BrowserClientOptions> {
WINDOW.document.addEventListener('visibilitychange', () => {
if (WINDOW.document.visibilityState === 'hidden') {
this._flushOutcomes();
if (this._options._experiments?.enableLogs) {
_INTERNAL_flushLogsBuffer(this);
}
}
});
}
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@ import type {
SpanContextData,
SpanJSON,
StartSpanOptions,
TraceContext,
TransactionEvent,
Transport,
TransportMakeRequestResponse,
Expand All@@ -44,7 +45,10 @@ import { afterSetupIntegrations } from './integration';
import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromScope } from './tracing/dynamicSamplingContext';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
} from './tracing/dynamicSamplingContext';
import { createClientReportEnvelope } from './utils-hoist/clientreport';
import { dsnToString, makeDsn } from './utils-hoist/dsn';
import { addItemToEnvelope, createAttachmentEnvelopeItem } from './utils-hoist/envelope';
Expand All@@ -57,8 +61,9 @@ import { getPossibleEventMessages } from './utils/eventUtils';
import { merge } from './utils/merge';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';
import { showSpanDropWarning } from './utils/spanUtils';
import { showSpanDropWarning, spanToTraceContext } from './utils/spanUtils';
import { convertSpanJsonToTransactionEvent, convertTransactionEventToSpanJson } from './utils/transactionEvent';
import { _getSpanForScope } from './utils/spanOnScope';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
const MISSING_RELEASE_FOR_SESSION_ERROR = 'Discarded session because of missing or non-string release';
Expand DownExpand Up@@ -617,7 +622,7 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
public on(hook: 'close', callback: () => void): () => void;

/**
* Register a hook oin this client.
* Register a hook on this client.
*/
public on(hook: string, callback: unknown): () => void {
const hooks = (this._hooks[hook] = this._hooks[hook] || []);
Expand DownExpand Up@@ -1256,3 +1261,20 @@ function isErrorEvent(event: Event): event is ErrorEvent {
function isTransactionEvent(event: Event): event is TransactionEvent {
return event.type === 'transaction';
}

/** Extract trace information from scope */
export function _getTraceInfoFromScope(
client: Client,
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);
const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(client, scope);
return [dynamicSamplingContext, traceContext];
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,6 +113,7 @@ export { instrumentFetchRequest } from './fetch';
export { trpcMiddleware } from './trpc';
export { captureFeedback } from './feedback';
export type { ReportDialogOptions } from './report-dialog';
export { _INTERNAL_flushLogsBuffer } from './logs';

// TODO: Make this structure pretty again and don't do "export *"
export * from './utils-hoist/index';
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/logs/constants.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
import type { LogSeverityLevel } from '../types-hoist';

/**
* Maps a log severity level to a log severity number.
*
* @see LogSeverityLevel
*/
export const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial<Record<LogSeverityLevel, number>> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};
51 changes: 51 additions & 0 deletions packages/core/src/logs/envelope.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import { createEnvelope } from '../utils-hoist';

import type { DsnComponents, SdkMetadata, SerializedOtelLog } from '../types-hoist';
import type { OtelLogEnvelope, OtelLogItem } from '../types-hoist/envelope';
import { dsnToString } from '../utils-hoist';

/**
* Creates OTEL log envelope item for a serialized OTEL log.
*
* @param log - The serialized OTEL log to include in the envelope.
* @returns The created OTEL log envelope item.
*/
export function createOtelLogEnvelopeItem(log: SerializedOtelLog): OtelLogItem {
return [
{
type: 'otel_log',
},
log,
];
}

/**
* Creates an envelope for a list of logs.
*
* @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 createOtelLogEnvelope(
logs: Array<SerializedOtelLog>,
metadata?: SdkMetadata,
tunnel?: string,
dsn?: DsnComponents,
Comment thread
AbhiPrasad marked this conversation as resolved.
): OtelLogEnvelope {
const headers: OtelLogEnvelope[0] = {};

if (metadata?.sdk) {
headers.sdk = {
name: metadata.sdk.name,
version: metadata.sdk.version,
};
}

if (!!tunnel && !!dsn) {
headers.dsn = dsnToString(dsn);
}

return createEnvelope<OtelLogEnvelope>(headers, logs.map(createOtelLogEnvelopeItem));
}
156 changes: 156 additions & 0 deletions packages/core/src/logs/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
import type { Client } from '../client';
import { _getTraceInfoFromScope } from '../client';
import { getClient, getCurrentScope } from '../currentScopes';
import { DEBUG_BUILD } from '../debug-build';
import { SEVERITY_TEXT_TO_SEVERITY_NUMBER } from './constants';
import type { SerializedLogAttribute, SerializedOtelLog } from '../types-hoist';
import type { Log } from '../types-hoist/log';
import { logger } from '../utils-hoist';
import { _getSpanForScope } from '../utils/spanOnScope';
import { createOtelLogEnvelope } from './envelope';

const MAX_LOG_BUFFER_SIZE = 100;
Comment thread
chargome marked this conversation as resolved.

const CLIENT_TO_LOG_BUFFER_MAP = new WeakMap<Client, Array<SerializedOtelLog>>();

/**
* Converts a log attribute to a serialized log attribute.
*
* @param key - The key of the log attribute.
* @param value - The value of the log attribute.
* @returns The serialized log attribute.
*/
export function logAttributeToSerializedLogAttribute(key: string, value: unknown): SerializedLogAttribute {
switch (typeof value) {
case 'number':
return {
key,
value: { doubleValue: value },
};
case 'boolean':
return {
key,
value: { boolValue: value },
};
case 'string':
return {
key,
value: { stringValue: value },
};
default: {
let stringValue = '';
try {
stringValue = JSON.stringify(value) ?? '';
} catch (_) {
// Do nothing
}
return {
key,
value: { stringValue },
};
}
}
}

/**
* Captures a log event and sends it to Sentry.
*
* @param log - The log event to capture.
* @param scope - A scope. Uses the current scope if not provided.
* @param client - A client. Uses the current client if not provided.
*
* @experimental This method will experience breaking changes. This is not yet part of
* the stable Sentry SDK API and can be changed or removed without warning.
*/
export function captureLog(log: Log, scope = getCurrentScope(), client = getClient()): void {
if (!client) {
DEBUG_BUILD && logger.warn('No client available to capture log.');
return;
}

const { _experiments, release, environment } = client.getOptions();
if (!_experiments?.enableLogs) {
DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.');
return;
}

const [, traceContext] = _getTraceInfoFromScope(client, scope);

const { level, message, attributes, severityNumber } = log;

const logAttributes = {
...attributes,
};

if (release) {
logAttributes.release = release;
}

if (environment) {
logAttributes.environment = environment;
}

const span = _getSpanForScope(scope);
if (span) {
// Add the parent span ID to the log attributes for trace context
logAttributes['sentry.trace.parent_span_id'] = span.spanContext().spanId;
}

const serializedLog: SerializedOtelLog = {
severityText: level,
body: {
stringValue: message,
},
attributes: Object.entries(logAttributes).map(([key, value]) => logAttributeToSerializedLogAttribute(key, value)),
timeUnixNano: `${new Date().getTime().toString()}000000`,
traceId: traceContext?.trace_id,
severityNumber: severityNumber ?? SEVERITY_TEXT_TO_SEVERITY_NUMBER[level],
};

const logBuffer = CLIENT_TO_LOG_BUFFER_MAP.get(client);
if (logBuffer === undefined) {
CLIENT_TO_LOG_BUFFER_MAP.set(client, [serializedLog]);
// Every time we initialize a new log buffer, we start a new interval to flush the buffer
return;
}

logBuffer.push(serializedLog);
if (logBuffer.length > MAX_LOG_BUFFER_SIZE) {
_INTERNAL_flushLogsBuffer(client, logBuffer);
}
}

/**
* Flushes the logs buffer to Sentry.
*
* @param client - A client.
* @param maybeLogBuffer - A log buffer. Uses the log buffer for the given client if not provided.
*/
export function _INTERNAL_flushLogsBuffer(client: Client, maybeLogBuffer?: Array<SerializedOtelLog>): void {

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

The only thing that is not ideal about this approach is that we have to expose this method to allow us to flush. This is as we need a way for the browser and server-runtime-client to get access to the logs buffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Another approach would be to register a callback on the client maybe?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

That still has bundle size hit because we need to add that stuff to the client. I'll think about this though, it's a good point.

const logBuffer = maybeLogBuffer ?? CLIENT_TO_LOG_BUFFER_MAP.get(client) ?? [];
if (logBuffer.length === 0) {
return;
}

const clientOptions = client.getOptions();
const envelope = createOtelLogEnvelope(logBuffer, clientOptions._metadata, clientOptions.tunnel, client.getDsn());

// Clear the log buffer after envelopes have been constructed.
logBuffer.length = 0;

// sendEnvelope should not throw
// eslint-disable-next-line @typescript-eslint/no-floating-promises
client.sendEnvelope(envelope);
}

/**
* Returns the log buffer for a given client.
*
* Exported for testing purposes.
*
* @param client - The client to get the log buffer for.
* @returns The log buffer for the given client.
*/
export function _INTERNAL_getLogBuffer(client: Client): Array<SerializedOtelLog> | undefined {
return CLIENT_TO_LOG_BUFFER_MAP.get(client);
}
33 changes: 4 additions & 29 deletions packages/core/src/server-runtime-client.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,32 +2,24 @@ import type {
BaseTransportOptions,
CheckIn,
ClientOptions,
DynamicSamplingContext,
Event,
EventHint,
MonitorConfig,
ParameterizedString,
SerializedCheckIn,
SeverityLevel,
TraceContext,
} from './types-hoist';

import { createCheckInEnvelope } from './checkin';
import { Client } from './client';
import { getIsolationScope, getTraceContextFromScope } from './currentScopes';
import { Client, _getTraceInfoFromScope } from './client';
import { getIsolationScope } from './currentScopes';
import { DEBUG_BUILD } from './debug-build';
import type { Scope } from './scope';
import {
getDynamicSamplingContextFromScope,
getDynamicSamplingContextFromSpan,
registerSpanErrorInstrumentation,
} from './tracing';
import { registerSpanErrorInstrumentation } from './tracing';
import { eventFromMessage, eventFromUnknownInput } from './utils-hoist/eventbuilder';
import { logger } from './utils-hoist/logger';
import { uuid4 } from './utils-hoist/misc';
import { resolvedSyncPromise } from './utils-hoist/syncpromise';
import { _getSpanForScope } from './utils/spanOnScope';
import { spanToTraceContext } from './utils/spanUtils';

export interface ServerRuntimeClientOptions extends ClientOptions<BaseTransportOptions> {
platform?: string;
Expand DownExpand Up@@ -136,7 +128,7 @@ export class ServerRuntimeClient<
};
}

const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope);
const [dynamicSamplingContext, traceContext] = _getTraceInfoFromScope(this, scope);
if (traceContext) {
serializedCheckIn.contexts = {
trace: traceContext,
Expand DownExpand Up@@ -186,23 +178,6 @@ export class ServerRuntimeClient<

return super._prepareEvent(event, hint, currentScope, isolationScope);
}

/** Extract trace information from scope */
protected _getTraceInfoFromScope(
scope: Scope | undefined,
): [dynamicSamplingContext: Partial<DynamicSamplingContext> | undefined, traceContext: TraceContext | undefined] {
if (!scope) {
return [undefined, undefined];
}

const span = _getSpanForScope(scope);

const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope);
const dynamicSamplingContext = span
? getDynamicSamplingContextFromSpan(span)
: getDynamicSamplingContextFromScope(this, scope);
return [dynamicSamplingContext, traceContext];
}
}

function setCurrentRequestSessionErroredOrCrashed(eventHint?: EventHint): void {
Expand Down
Loading