diff --git a/.size-limit.js b/.size-limit.js index 08adf5a80c29..d312dbcdac3b 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -8,7 +8,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init'), gzip: true, - limit: '24 KB', + limit: '25 KB', }, { name: '@sentry/browser - with treeshaking flags', @@ -40,14 +40,14 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '37.5 KB', + limit: '38 KB', }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '75 KB', + limit: '77 KB', }, { name: '@sentry/browser (incl. Tracing, Replay) - with treeshaking flags', @@ -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: '82 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -210,7 +210,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '41 KB', + limit: '43 KB', }, // SvelteKit SDK (ESM) { diff --git a/CHANGELOG.md b/CHANGELOG.md index bddbe3762189..71d8d3ba0f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,42 @@ Work in this release was contributed by @6farer. Thank you for your contribution! +## 9.2.0-alpha.1 + +Alpha release of User Feedback's new annotation editor that allows you to highlight (or redact) specific parts of a screenshot. + +- feat(feedback): Allowing annotation via highlighting & masking (#15484) + +## 9.2.0-alpha.0 + +This is an alpha release that includes experimental functionality for the new logs API in Sentry. Support for these methods are only avaliable in the browser and core SDKs. + +- feat(logs): Add experimental user-callable logging methods (#15442) + +Logging is gated by an experimental option, `_experiments.enableLogs`. + +```js +Sentry.init({ + _experiments: { + enableLogs: true, + }, +}); +``` + +These API are exposed in the `Sentry._experiment_log` namespace. + +On the high level, there are functions for each of the logging severity levels `critical`, `fatal`, `error`, `warn`, `info`, `debug`, `trace`. These functions are tagged template functions, so they use a special string template syntax that we use to parameterize functions accordingly. + +```js +Sentry._experiment_log.info`user ${username} just bought ${item}!`; +``` + +If you want more custom usage, we also expose a `captureLog` method that allows you to pass custom attributes, but it's less easy to use than the tagged template functions. + +```js +Sentry._experiment_log.captureLog('error', 'Hello world!', { 'user.id': 123 }); +``` + ## 9.1.0 - feat(browser): Add `graphqlClientIntegration` ([#13783](https://github.com/getsentry/sentry-javascript/pull/13783)) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 3380f6a07352..aa9e63fa9c7f 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-integration-tests", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "main": "index.js", "license": "MIT", "engines": { @@ -42,7 +42,7 @@ "@babel/preset-typescript": "^7.16.7", "@playwright/test": "~1.50.0", "@sentry-internal/rrweb": "2.32.0", - "@sentry/browser": "9.1.0", + "@sentry/browser": "9.2.0-alpha.1", "axios": "1.7.7", "babel-loader": "^8.2.2", "fflate": "0.8.2", diff --git a/dev-packages/bundle-analyzer-scenarios/package.json b/dev-packages/bundle-analyzer-scenarios/package.json index 5b5f04fece4f..cd0d5a4f2cf4 100644 --- a/dev-packages/bundle-analyzer-scenarios/package.json +++ b/dev-packages/bundle-analyzer-scenarios/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/bundle-analyzer-scenarios", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Scenarios to test bundle analysis with", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/dev-packages/bundle-analyzer-scenarios", diff --git a/dev-packages/clear-cache-gh-action/package.json b/dev-packages/clear-cache-gh-action/package.json index 2d485501d38e..b86effd1112d 100644 --- a/dev-packages/clear-cache-gh-action/package.json +++ b/dev-packages/clear-cache-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/clear-cache-gh-action", "description": "An internal Github Action to clear GitHub caches.", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index eedbab432243..32b53e65f88f 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/e2e-tests", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "license": "MIT", "private": true, "scripts": { diff --git a/dev-packages/external-contributor-gh-action/package.json b/dev-packages/external-contributor-gh-action/package.json index 078572e6fcfe..2955f0358a36 100644 --- a/dev-packages/external-contributor-gh-action/package.json +++ b/dev-packages/external-contributor-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/external-contributor-gh-action", "description": "An internal Github Action to add external contributors to the CHANGELOG.md file.", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index bbb7e300ecee..50c9e2910604 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/node-integration-tests", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "license": "MIT", "engines": { "node": ">=18" @@ -31,9 +31,9 @@ "@nestjs/common": "10.4.6", "@nestjs/core": "10.4.6", "@nestjs/platform-express": "10.4.6", - "@sentry/aws-serverless": "9.1.0", - "@sentry/core": "9.1.0", - "@sentry/node": "9.1.0", + "@sentry/aws-serverless": "9.2.0-alpha.1", + "@sentry/core": "9.2.0-alpha.1", + "@sentry/node": "9.2.0-alpha.1", "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", diff --git a/dev-packages/rollup-utils/package.json b/dev-packages/rollup-utils/package.json index c35e55fa8cb0..4ab8850789f2 100644 --- a/dev-packages/rollup-utils/package.json +++ b/dev-packages/rollup-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/rollup-utils", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Rollup utilities used at Sentry for the Sentry JavaScript SDK", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/rollup-utils", diff --git a/dev-packages/size-limit-gh-action/package.json b/dev-packages/size-limit-gh-action/package.json index 0a4fafb0923e..dfdf0fd4a2c5 100644 --- a/dev-packages/size-limit-gh-action/package.json +++ b/dev-packages/size-limit-gh-action/package.json @@ -1,7 +1,7 @@ { "name": "@sentry-internal/size-limit-gh-action", "description": "An internal Github Action to compare the current size of a PR against the one on develop.", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "license": "MIT", "engines": { "node": ">=18" diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 6e84bb4946d8..1207f4c23423 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "9.1.0", + "version": "9.2.0-alpha.1", "name": "@sentry-internal/test-utils", "author": "Sentry", "license": "MIT", @@ -45,7 +45,7 @@ }, "devDependencies": { "@playwright/test": "~1.50.0", - "@sentry/core": "9.1.0" + "@sentry/core": "9.2.0-alpha.1" }, "volta": { "extends": "../../package.json" diff --git a/lerna.json b/lerna.json index 20be3bfcd101..f2883eebf2e6 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "npmClient": "yarn" } diff --git a/packages/angular/package.json b/packages/angular/package.json index f315baf655e4..e2a55e72a509 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/angular", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for Angular", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/angular", @@ -21,8 +21,8 @@ "rxjs": "^6.5.5 || ^7.x" }, "dependencies": { - "@sentry/browser": "9.1.0", - "@sentry/core": "9.1.0", + "@sentry/browser": "9.2.0-alpha.1", + "@sentry/core": "9.2.0-alpha.1", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 563dc6fac385..134bfad7d7bc 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/astro", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for Astro", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/astro", @@ -56,9 +56,9 @@ "astro": ">=3.x || >=4.0.0-beta || >=5.x" }, "dependencies": { - "@sentry/browser": "9.1.0", - "@sentry/core": "9.1.0", - "@sentry/node": "9.1.0", + "@sentry/browser": "9.2.0-alpha.1", + "@sentry/core": "9.2.0-alpha.1", + "@sentry/node": "9.2.0-alpha.1", "@sentry/vite-plugin": "^2.22.6" }, "devDependencies": { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 9b1789ef7182..349e63a5b84c 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/aws-serverless", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for AWS Lambda and AWS Serverless Environments", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/serverless", @@ -68,8 +68,8 @@ "@opentelemetry/instrumentation": "^0.57.2", "@opentelemetry/instrumentation-aws-lambda": "0.50.3", "@opentelemetry/instrumentation-aws-sdk": "0.49.1", - "@sentry/core": "9.1.0", - "@sentry/node": "9.1.0", + "@sentry/core": "9.2.0-alpha.1", + "@sentry/node": "9.2.0-alpha.1", "@types/aws-lambda": "^8.10.62" }, "devDependencies": { diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 8e24d7524fc5..8429f75590a7 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/browser-utils", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Browser Utilities for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser-utils", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.1.0" + "@sentry/core": "9.2.0-alpha.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/browser/package.json b/packages/browser/package.json index 1fc61a897f5a..e4c4d349efa2 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/browser", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for browsers", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/browser", @@ -39,14 +39,14 @@ "access": "public" }, "dependencies": { - "@sentry-internal/browser-utils": "9.1.0", - "@sentry-internal/feedback": "9.1.0", - "@sentry-internal/replay": "9.1.0", - "@sentry-internal/replay-canvas": "9.1.0", - "@sentry/core": "9.1.0" + "@sentry-internal/browser-utils": "9.2.0-alpha.1", + "@sentry-internal/feedback": "9.2.0-alpha.1", + "@sentry-internal/replay": "9.2.0-alpha.1", + "@sentry-internal/replay-canvas": "9.2.0-alpha.1", + "@sentry/core": "9.2.0-alpha.1" }, "devDependencies": { - "@sentry-internal/integration-shims": "9.1.0", + "@sentry-internal/integration-shims": "9.2.0-alpha.1", "fake-indexeddb": "^4.0.1" }, "scripts": { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 63da52dfd30e..78ddf94246ef 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -10,6 +10,7 @@ export { extraErrorDataIntegration, rewriteFramesIntegration, captureFeedback, + _experiment_log, } from '@sentry/core'; export { replayIntegration, getReplay } from '@sentry-internal/replay'; diff --git a/packages/bun/package.json b/packages/bun/package.json index cab5a593caca..56cf75c412a4 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/bun", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for bun", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/bun", @@ -39,9 +39,9 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.1.0", - "@sentry/node": "9.1.0", - "@sentry/opentelemetry": "9.1.0" + "@sentry/core": "9.2.0-alpha.1", + "@sentry/node": "9.2.0-alpha.1", + "@sentry/opentelemetry": "9.2.0-alpha.1" }, "devDependencies": { "bun-types": "latest" diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 9d40d3853937..01fa83ac8ba5 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/cloudflare", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for Cloudflare Workers and Pages", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/cloudflare", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.1.0" + "@sentry/core": "9.2.0-alpha.1" }, "optionalDependencies": { "@cloudflare/workers-types": "^4.x" diff --git a/packages/core/package.json b/packages/core/package.json index 12f0a26d24ae..5152d13491e4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/core", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Base implementation for all Sentry JavaScript SDKs", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/core", diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index 4854ee86efb8..c64bc1a86a48 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -1,5 +1,6 @@ import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; +import { captureLog, sendLog } from './log'; import type { CaptureContext } from './scope'; import { closeSession, makeSession, updateSession } from './session'; import type { @@ -10,6 +11,7 @@ import type { Extra, Extras, FinishedCheckIn, + LogSeverityLevel, MonitorConfig, Primitive, Session, @@ -334,3 +336,68 @@ export function captureSession(end: boolean = false): void { // only send the update _sendSessionUpdate(); } + +type OmitFirstArg = F extends (x: LogSeverityLevel, ...args: infer P) => infer R ? (...args: P) => R : never; + +/** + * A namespace for experimental logging functions. + * + * @experimental Will be removed in future versions. Do not use. + */ +export const _experiment_log = { + /** + * A utility to record a log with level 'TRACE' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * Ex: Sentry._experiment_log.trace`user ${username} just bought ${item}!` + */ + trace: sendLog.bind(null, 'trace') as OmitFirstArg, + /** + * A utility to record a log with level 'DEBUG' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * Ex: Sentry._experiment_log.debug`user ${username} just bought ${item}!` + */ + debug: sendLog.bind(null, 'debug') as OmitFirstArg, + /** + * A utility to record a log with level 'INFO' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * Ex: Sentry._experiment_log.info`user ${username} just bought ${item}!` + */ + info: sendLog.bind(null, 'info') as OmitFirstArg, + /** + * A utility to record a log with level 'INFO' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * Ex: Sentry._experiment_log.log`user ${username} just bought ${item}!` + */ + log: sendLog.bind(null, 'log') as OmitFirstArg, + /** + * A utility to record a log with level 'ERROR' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * Ex: Sentry._experiment_log.error`user ${username} just bought ${item}!` + */ + error: sendLog.bind(null, 'error') as OmitFirstArg, + /** + * A utility to record a log with level 'WARN' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * Ex: Sentry._experiment_log.warn`user ${username} just bought ${item}!` + */ + warn: sendLog.bind(null, 'warn') as OmitFirstArg, + /** + * A utility to record a log with level 'FATAL' and send it to sentry. + * + * Logs represent a message and some parameters which provide context for a trace or error. + * Ex: Sentry._experiment_log.warn`user ${username} just bought ${item}!` + */ + fatal: sendLog.bind(null, 'fatal') as OmitFirstArg, + /** + * A flexible utility to record a log with a custom level and send it to sentry. + * + * You can optionally pass in custom attributes and a custom severity number to be attached to the log. + */ + captureLog, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0e9097bbc53..f8ac69ed0b97 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -29,6 +29,7 @@ export { endSession, captureSession, addEventProcessor, + _experiment_log, } from './exports'; export { getCurrentScope, diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 000000000000..a0202f102622 --- /dev/null +++ b/packages/core/src/log.ts @@ -0,0 +1,188 @@ +import type { Client } from './client'; +import { getClient, getCurrentScope } from './currentScopes'; +import { DEBUG_BUILD } from './debug-build'; +import type { Scope } from './scope'; +import { getDynamicSamplingContextFromScope } from './tracing'; +import type { DynamicSamplingContext, LogEnvelope, LogItem } from './types-hoist/envelope'; +import type { Log, LogAttribute, LogSeverityLevel } from './types-hoist/log'; +import { createEnvelope, dropUndefinedKeys, dsnToString, logger } from './utils-hoist'; + +const LOG_BUFFER_MAX_LENGTH = 25; + +let GLOBAL_LOG_BUFFER: Log[] = []; + +let isFlushingLogs = false; + +const SEVERITY_TEXT_TO_SEVERITY_NUMBER: Partial> = { + trace: 1, + debug: 5, + info: 9, + log: 10, + warn: 13, + error: 17, + fatal: 21, +}; + +/** + * Creates envelope item for a single log + */ +export function createLogEnvelopeItem(log: Log): LogItem { + const headers: LogItem[0] = { + type: 'otel_log', + }; + + return [headers, log]; +} + +/** + * Records a log and sends it to sentry. + * + * Logs represent a message (and optionally some structured data) which provide context for a trace or error. + * Ex: sentry.addLog({level: 'warning', message: `user ${user} just bought ${item}`, attributes: {user, item}} + * + * @params log - the log object which will be sent + */ +function createLogEnvelope(logs: Log[], client: Client, scope: Scope): LogEnvelope { + const dsc = getDynamicSamplingContextFromScope(client, scope); + + const dsn = client.getDsn(); + + const headers: LogEnvelope[0] = { + trace: dropUndefinedKeys(dsc) as DynamicSamplingContext, + ...(dsn ? { dsn: dsnToString(dsn) } : {}), + }; + + return createEnvelope(headers, logs.map(createLogEnvelopeItem)); +} + +function valueToAttribute(key: string, value: unknown): LogAttribute { + 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: + return { + key, + value: { stringValue: JSON.stringify(value) }, + }; + } +} + +function addToLogBuffer(client: Client, log: Log, scope: Scope): void { + function sendLogs(flushedLogs: Log[]): void { + const envelope = createLogEnvelope(flushedLogs, client, scope); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + void client.sendEnvelope(envelope); + } + + if (GLOBAL_LOG_BUFFER.length >= LOG_BUFFER_MAX_LENGTH) { + sendLogs(GLOBAL_LOG_BUFFER); + GLOBAL_LOG_BUFFER = []; + } else { + GLOBAL_LOG_BUFFER.push(log); + } + + // this is the first time logs have been enabled, let's kick off an interval to flush them + // we should only do this once. + if (!isFlushingLogs) { + setInterval(() => { + if (GLOBAL_LOG_BUFFER.length > 0) { + sendLogs(GLOBAL_LOG_BUFFER); + GLOBAL_LOG_BUFFER = []; + } + }, 5000); + } + isFlushingLogs = true; +} + +/** + * A utility function to be able to create methods like Sentry.info`...` that use tagged template functions. + * + * The first parameter is bound with, e.g., const info = captureLog.bind(null, 'info') + * The other parameters are in the format to be passed a tagged template, Sentry.info`hello ${world}` + */ +export function sendLog(level: LogSeverityLevel, messageArr: TemplateStringsArray, ...values: unknown[]): void { + const message = messageArr.reduce((acc, str, i) => acc + str + (values[i] ?? ''), ''); + + const attributes = values.reduce>( + (acc, value, index) => { + acc[`param${index}`] = value; + return acc; + }, + { + 'sentry.template': messageArr.map((s, i) => s + (i < messageArr.length - 1 ? `$param${i}` : '')).join(''), + }, + ); + + captureLog(level, message, attributes); +} + +/** + * Sends a log to Sentry. + */ +export function captureLog( + level: LogSeverityLevel, + message: string, + customAttributes: Record = {}, + severityNumber?: number, +): void { + const client = getClient(); + + if (!client) { + DEBUG_BUILD && logger.warn('No client available, log will not be captured.'); + return; + } + + if (!client.getOptions()._experiments?.enableLogs) { + DEBUG_BUILD && logger.warn('logging option not enabled, log will not be captured.'); + return; + } + + const { release, environment } = client.getOptions(); + + const logAttributes = { + ...customAttributes, + }; + + if (release) { + logAttributes['sentry.release'] = release; + } + + if (environment) { + logAttributes['sentry.environment'] = environment; + } + + const scope = getCurrentScope(); + + const attributes = Object.entries(logAttributes).map(([key, value]) => valueToAttribute(key, value)); + + const log: Log = { + severityText: level, + body: { + stringValue: message, + }, + attributes, + timeUnixNano: `${new Date().getTime().toString()}000000`, + traceId: scope.getPropagationContext().traceId, + severityNumber, + }; + + const maybeSeverityNumber = SEVERITY_TEXT_TO_SEVERITY_NUMBER[level]; + if (maybeSeverityNumber !== undefined && log.severityNumber === undefined) { + log.severityNumber = maybeSeverityNumber; + } + + addToLogBuffer(client, log, scope); +} diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts index 5a54ffc7b8c2..d78cccc8384a 100644 --- a/packages/core/src/types-hoist/envelope.ts +++ b/packages/core/src/types-hoist/envelope.ts @@ -5,6 +5,7 @@ import type { LegacyCSPReport } from './csp'; import type { DsnComponents } from './dsn'; import type { Event } from './event'; import type { FeedbackEvent, UserFeedback } from './feedback'; +import type { Log } from './log'; import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; @@ -43,6 +44,7 @@ export type EnvelopeItemType = | 'replay_recording' | 'check_in' | 'span' + | 'otel_log' | 'raw_security'; export type BaseEnvelopeHeaders = { @@ -85,6 +87,7 @@ type CheckInItemHeaders = { type: 'check_in' }; type ProfileItemHeaders = { type: 'profile' }; type ProfileChunkItemHeaders = { type: 'profile_chunk' }; type SpanItemHeaders = { type: 'span' }; +type LogItemHeaders = { type: 'otel_log' }; type RawSecurityHeaders = { type: 'raw_security'; sentry_release?: string; sentry_environment?: string }; export type EventItem = BaseEnvelopeItem; @@ -101,6 +104,7 @@ export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; +export type LogItem = BaseEnvelopeItem; export type RawSecurityItem = BaseEnvelopeItem; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: Partial }; @@ -109,6 +113,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type SpanEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; +type LogEnvelopeHeaders = BaseEnvelopeHeaders & { trace?: DynamicSamplingContext }; export type EventEnvelope = BaseEnvelope< EventEnvelopeHeaders, @@ -121,6 +126,7 @@ export type CheckInEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; export type ProfileChunkEnvelope = BaseEnvelope; export type RawSecurityEnvelope = BaseEnvelope; +export type LogEnvelope = BaseEnvelope; export type Envelope = | EventEnvelope @@ -130,6 +136,7 @@ export type Envelope = | ReplayEnvelope | CheckInEnvelope | SpanEnvelope - | RawSecurityEnvelope; + | RawSecurityEnvelope + | LogEnvelope; export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/core/src/types-hoist/feedback/config.ts b/packages/core/src/types-hoist/feedback/config.ts index d7b3d78995bb..4ec846c7d98d 100644 --- a/packages/core/src/types-hoist/feedback/config.ts +++ b/packages/core/src/types-hoist/feedback/config.ts @@ -57,15 +57,6 @@ export interface FeedbackGeneralConfiguration { name: string; }; - /** - * _experiments allows users to enable experimental or internal features. - * We don't consider such features as part of the public API and hence we don't guarantee semver for them. - * Experimental features can be added, changed or removed at any time. - * - * Default: undefined - */ - _experiments: Partial<{ annotations: boolean }>; - /** * Set an object that will be merged sent as tags data with the event. */ diff --git a/packages/core/src/types-hoist/index.ts b/packages/core/src/types-hoist/index.ts index c1cbe5284808..57bacb75c7d2 100644 --- a/packages/core/src/types-hoist/index.ts +++ b/packages/core/src/types-hoist/index.ts @@ -113,6 +113,12 @@ export type { SpanContextData, TraceFlag, } from './span'; +export type { + Log, + LogAttribute, + LogSeverityLevel, + LogAttributeValueType, +} from './log'; export type { SpanStatus } from './spanStatus'; export type { TimedEvent } from './timedEvent'; export type { StackFrame } from './stackframe'; diff --git a/packages/core/src/types-hoist/log.ts b/packages/core/src/types-hoist/log.ts new file mode 100644 index 000000000000..a4ca06133a2c --- /dev/null +++ b/packages/core/src/types-hoist/log.ts @@ -0,0 +1,69 @@ +export type LogSeverityLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'critical'; + +export type LogAttributeValueType = + | { + stringValue: string; + } + | { + intValue: number; + } + | { + boolValue: boolean; + } + | { + doubleValue: number; + }; + +export type LogAttribute = { + key: string; + value: LogAttributeValueType; +}; + +export interface Log { + /** + * Allowed values are, from highest to lowest: + * `critical`, `fatal`, `error`, `warn`, `info`, `debug`, `trace`. + * + * The log level changes how logs are filtered and displayed. + * Critical level logs are emphasized more than trace level logs. + * + * @summary The severity level of the log. + */ + severityText?: LogSeverityLevel; + + /** + * The severity number - generally higher severity are levels like 'error' and lower are levels like 'debug' + */ + severityNumber?: number; + + /** + * OTEL trace flags (bitmap) - currently 1 means sampled, 0 means unsampled - for sentry always set to 0 + */ + traceFlags?: number; + + /** + * The trace ID for this log + */ + traceId?: string; + + /** + * The message to be logged - for example, 'hello world' would become a log like '[INFO] hello world' + */ + body: { + stringValue: string; + }; + + /** + * Arbitrary structured data that stores information about the log - e.g., userId: 100. + */ + attributes?: LogAttribute[]; + + /** + * This doesn't have to be explicitly specified most of the time. If you need to set it, the value + * is the number of seconds since midnight on January 1, 1970 ("unix epoch time") + * + * @summary A timestamp representing when the log occurred. + * @link https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#:~:text=is%20info.-,timestamp,-(recommended) + */ + timeUnixNano?: string; +} diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts index 8e52b32eacf7..d0474b959fa9 100644 --- a/packages/core/src/types-hoist/options.ts +++ b/packages/core/src/types-hoist/options.ts @@ -182,7 +182,12 @@ export interface ClientOptions = { feedback: 'feedback', span: 'span', raw_security: 'security', + otel_log: 'log_item', }; /** diff --git a/packages/deno/package.json b/packages/deno/package.json index a614a67b24ac..4e2f975e5918 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/deno", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for Deno", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/deno", @@ -24,7 +24,7 @@ "/build" ], "dependencies": { - "@sentry/core": "9.1.0" + "@sentry/core": "9.2.0-alpha.1" }, "scripts": { "deno-types": "node ./scripts/download-deno-types.mjs", diff --git a/packages/ember/package.json b/packages/ember/package.json index ff7cacc66601..e89e8785225c 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/ember", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK for Ember.js", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/ember", @@ -32,8 +32,8 @@ "dependencies": { "@babel/core": "^7.24.4", "@embroider/macros": "^1.16.0", - "@sentry/browser": "9.1.0", - "@sentry/core": "9.1.0", + "@sentry/browser": "9.2.0-alpha.1", + "@sentry/core": "9.2.0-alpha.1", "ember-auto-import": "^2.7.2", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^6.1.1", diff --git a/packages/eslint-config-sdk/package.json b/packages/eslint-config-sdk/package.json index 19525dff960e..73493355f2f6 100644 --- a/packages/eslint-config-sdk/package.json +++ b/packages/eslint-config-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-config-sdk", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK eslint config", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-config-sdk", @@ -22,8 +22,8 @@ "access": "public" }, "dependencies": { - "@sentry-internal/eslint-plugin-sdk": "9.1.0", - "@sentry-internal/typescript": "9.1.0", + "@sentry-internal/eslint-plugin-sdk": "9.2.0-alpha.1", + "@sentry-internal/typescript": "9.2.0-alpha.1", "@typescript-eslint/eslint-plugin": "^5.48.0", "@typescript-eslint/parser": "^5.48.0", "eslint-config-prettier": "^6.11.0", diff --git a/packages/eslint-plugin-sdk/package.json b/packages/eslint-plugin-sdk/package.json index b451073de5f1..d727490c59d6 100644 --- a/packages/eslint-plugin-sdk/package.json +++ b/packages/eslint-plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/eslint-plugin-sdk", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Official Sentry SDK eslint plugin", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/eslint-plugin-sdk", diff --git a/packages/feedback/package.json b/packages/feedback/package.json index ec6782f6f072..04991ae1b070 100644 --- a/packages/feedback/package.json +++ b/packages/feedback/package.json @@ -1,6 +1,6 @@ { "name": "@sentry-internal/feedback", - "version": "9.1.0", + "version": "9.2.0-alpha.1", "description": "Sentry SDK integration for user feedback", "repository": "git://github.com/getsentry/sentry-javascript.git", "homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/feedback", @@ -39,7 +39,7 @@ "access": "public" }, "dependencies": { - "@sentry/core": "9.1.0" + "@sentry/core": "9.2.0-alpha.1" }, "devDependencies": { "preact": "^10.19.4" diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 8b312b902258..e5f1092856f1 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -84,7 +84,6 @@ export const buildFeedbackIntegration = ({ email: 'email', name: 'username', }, - _experiments = {}, tags, styleNonce, scriptNonce, @@ -159,8 +158,6 @@ export const buildFeedbackIntegration = ({ onSubmitError, onSubmitSuccess, onFormSubmitted, - - _experiments, }; let _shadow: ShadowRoot | null = null; diff --git a/packages/feedback/src/screenshot/components/Annotations.tsx b/packages/feedback/src/screenshot/components/Annotations.tsx deleted file mode 100644 index eb897b40f166..000000000000 --- a/packages/feedback/src/screenshot/components/Annotations.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { VNode, h as hType } from 'preact'; -import type * as Hooks from 'preact/hooks'; -import { DOCUMENT } from '../../constants'; - -interface FactoryParams { - h: typeof hType; -} - -export default function AnnotationsFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function Annotations({ - action, - imageBuffer, - annotatingRef, - }: { - action: 'crop' | 'annotate' | ''; - imageBuffer: HTMLCanvasElement; - annotatingRef: Hooks.Ref; - }): VNode { - const onAnnotateStart = (): void => { - if (action !== 'annotate') { - return; - } - - const handleMouseMove = (moveEvent: MouseEvent): void => { - const annotateCanvas = annotatingRef.current; - if (annotateCanvas) { - const rect = annotateCanvas.getBoundingClientRect(); - const x = moveEvent.clientX - rect.x; - const y = moveEvent.clientY - rect.y; - - const ctx = annotateCanvas.getContext('2d'); - if (ctx) { - ctx.lineTo(x, y); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(x, y); - } - } - }; - - const handleMouseUp = (): void => { - const ctx = annotatingRef.current?.getContext('2d'); - if (ctx) { - ctx.beginPath(); - } - - // Add your apply annotation logic here - applyAnnotation(); - - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - }; - - const applyAnnotation = (): void => { - // Logic to apply the annotation - const imageCtx = imageBuffer.getContext('2d'); - const annotateCanvas = annotatingRef.current; - if (imageCtx && annotateCanvas) { - imageCtx.drawImage( - annotateCanvas, - 0, - 0, - annotateCanvas.width, - annotateCanvas.height, - 0, - 0, - imageBuffer.width, - imageBuffer.height, - ); - - const annotateCtx = annotateCanvas.getContext('2d'); - if (annotateCtx) { - annotateCtx.clearRect(0, 0, annotateCanvas.width, annotateCanvas.height); - } - } - }; - return ( - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/Crop.tsx b/packages/feedback/src/screenshot/components/Crop.tsx deleted file mode 100644 index e019d8c510e0..000000000000 --- a/packages/feedback/src/screenshot/components/Crop.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import type { FeedbackInternalOptions } from '@sentry/core'; -import type { VNode, h as hType } from 'preact'; -import type * as Hooks from 'preact/hooks'; -import { DOCUMENT, WINDOW } from '../../constants'; -import CropCornerFactory from './CropCorner'; - -const CROP_BUTTON_SIZE = 30; -const CROP_BUTTON_BORDER = 3; -const CROP_BUTTON_OFFSET = CROP_BUTTON_SIZE + CROP_BUTTON_BORDER; -const DPI = WINDOW.devicePixelRatio; - -interface Box { - startX: number; - startY: number; - endX: number; - endY: number; -} - -interface Rect { - x: number; - y: number; - height: number; - width: number; -} - -const constructRect = (box: Box): Rect => ({ - x: Math.min(box.startX, box.endX), - y: Math.min(box.startY, box.endY), - width: Math.abs(box.startX - box.endX), - height: Math.abs(box.startY - box.endY), -}); - -const getContainedSize = (img: HTMLCanvasElement): Rect => { - const imgClientHeight = img.clientHeight; - const imgClientWidth = img.clientWidth; - const ratio = img.width / img.height; - let width = imgClientHeight * ratio; - let height = imgClientHeight; - if (width > imgClientWidth) { - width = imgClientWidth; - height = imgClientWidth / ratio; - } - const x = (imgClientWidth - width) / 2; - const y = (imgClientHeight - height) / 2; - return { x: x, y: y, width: width, height: height }; -}; - -interface FactoryParams { - h: typeof hType; - hooks: typeof Hooks; - options: FeedbackInternalOptions; -} - -export default function CropFactory({ h, hooks, options }: FactoryParams): (props: { - action: 'crop' | 'annotate' | ''; - imageBuffer: HTMLCanvasElement; - croppingRef: Hooks.Ref; - cropContainerRef: Hooks.Ref; - croppingRect: Box; - setCroppingRect: Hooks.StateUpdater; - resize: () => void; -}) => VNode { - const CropCorner = CropCornerFactory({ h }); - return function Crop({ - action, - imageBuffer, - croppingRef, - cropContainerRef, - croppingRect, - setCroppingRect, - resize, - }: { - action: 'crop' | 'annotate' | ''; - imageBuffer: HTMLCanvasElement; - croppingRef: Hooks.Ref; - cropContainerRef: Hooks.Ref; - croppingRect: Box; - setCroppingRect: Hooks.StateUpdater; - resize: () => void; - }): VNode { - const initialPositionRef = hooks.useRef({ initialX: 0, initialY: 0 }); - - const [isResizing, setIsResizing] = hooks.useState(false); - const [confirmCrop, setConfirmCrop] = hooks.useState(false); - - hooks.useEffect(() => { - const cropper = croppingRef.current; - if (!cropper) { - return; - } - - const ctx = cropper.getContext('2d'); - if (!ctx) { - return; - } - - const imageDimensions = getContainedSize(imageBuffer); - const croppingBox = constructRect(croppingRect); - ctx.clearRect(0, 0, imageDimensions.width, imageDimensions.height); - - if (action !== 'crop') { - return; - } - - // draw gray overlay around the selection - ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; - ctx.fillRect(0, 0, imageDimensions.width, imageDimensions.height); - ctx.clearRect(croppingBox.x, croppingBox.y, croppingBox.width, croppingBox.height); - - // draw selection border - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 3; - ctx.strokeRect(croppingBox.x + 1, croppingBox.y + 1, croppingBox.width - 2, croppingBox.height - 2); - ctx.strokeStyle = '#000000'; - ctx.lineWidth = 1; - ctx.strokeRect(croppingBox.x + 3, croppingBox.y + 3, croppingBox.width - 6, croppingBox.height - 6); - }, [croppingRect, action]); - - // Resizing logic - const makeHandleMouseMove = hooks.useCallback((corner: string) => { - return (e: MouseEvent) => { - if (!croppingRef.current) { - return; - } - - const cropCanvas = croppingRef.current; - const cropBoundingRect = cropCanvas.getBoundingClientRect(); - const mouseX = e.clientX - cropBoundingRect.x; - const mouseY = e.clientY - cropBoundingRect.y; - - switch (corner) { - case 'top-left': - setCroppingRect(prev => ({ - ...prev, - startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), - startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), - })); - break; - case 'top-right': - setCroppingRect(prev => ({ - ...prev, - endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), - startY: Math.min(Math.max(0, mouseY), prev.endY - CROP_BUTTON_OFFSET), - })); - break; - case 'bottom-left': - setCroppingRect(prev => ({ - ...prev, - startX: Math.min(Math.max(0, mouseX), prev.endX - CROP_BUTTON_OFFSET), - endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), - })); - break; - case 'bottom-right': - setCroppingRect(prev => ({ - ...prev, - endX: Math.max(Math.min(mouseX, cropCanvas.width / DPI), prev.startX + CROP_BUTTON_OFFSET), - endY: Math.max(Math.min(mouseY, cropCanvas.height / DPI), prev.startY + CROP_BUTTON_OFFSET), - })); - break; - } - }; - }, []); - - // Dragging logic - const onDragStart = (e: MouseEvent): void => { - if (isResizing) { - return; - } - - initialPositionRef.current = { initialX: e.clientX, initialY: e.clientY }; - - const handleMouseMove = (moveEvent: MouseEvent): void => { - const cropCanvas = croppingRef.current; - if (!cropCanvas) { - return; - } - - const deltaX = moveEvent.clientX - initialPositionRef.current.initialX; - const deltaY = moveEvent.clientY - initialPositionRef.current.initialY; - - setCroppingRect(prev => { - const newStartX = Math.max( - 0, - Math.min(prev.startX + deltaX, cropCanvas.width / DPI - (prev.endX - prev.startX)), - ); - const newStartY = Math.max( - 0, - Math.min(prev.startY + deltaY, cropCanvas.height / DPI - (prev.endY - prev.startY)), - ); - - const newEndX = newStartX + (prev.endX - prev.startX); - const newEndY = newStartY + (prev.endY - prev.startY); - - initialPositionRef.current.initialX = moveEvent.clientX; - initialPositionRef.current.initialY = moveEvent.clientY; - - return { startX: newStartX, startY: newStartY, endX: newEndX, endY: newEndY }; - }); - }; - - const handleMouseUp = (): void => { - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - }; - - DOCUMENT.addEventListener('mousemove', handleMouseMove); - DOCUMENT.addEventListener('mouseup', handleMouseUp); - }; - - const onGrabButton = (e: Event, corner: string): void => { - setIsResizing(true); - const handleMouseMove = makeHandleMouseMove(corner); - const handleMouseUp = (): void => { - DOCUMENT.removeEventListener('mousemove', handleMouseMove); - DOCUMENT.removeEventListener('mouseup', handleMouseUp); - setConfirmCrop(true); - setIsResizing(false); - }; - - DOCUMENT.addEventListener('mouseup', handleMouseUp); - DOCUMENT.addEventListener('mousemove', handleMouseMove); - }; - - function applyCrop(): void { - const cutoutCanvas = DOCUMENT.createElement('canvas'); - const imageBox = getContainedSize(imageBuffer); - const croppingBox = constructRect(croppingRect); - cutoutCanvas.width = croppingBox.width * DPI; - cutoutCanvas.height = croppingBox.height * DPI; - - const cutoutCtx = cutoutCanvas.getContext('2d'); - if (cutoutCtx && imageBuffer) { - cutoutCtx.drawImage( - imageBuffer, - (croppingBox.x / imageBox.width) * imageBuffer.width, - (croppingBox.y / imageBox.height) * imageBuffer.height, - (croppingBox.width / imageBox.width) * imageBuffer.width, - (croppingBox.height / imageBox.height) * imageBuffer.height, - 0, - 0, - cutoutCanvas.width, - cutoutCanvas.height, - ); - } - - const ctx = imageBuffer.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, imageBuffer.width, imageBuffer.height); - imageBuffer.width = cutoutCanvas.width; - imageBuffer.height = cutoutCanvas.height; - imageBuffer.style.width = `${croppingBox.width}px`; - imageBuffer.style.height = `${croppingBox.height}px`; - ctx.drawImage(cutoutCanvas, 0, 0); - - resize(); - } - } - - return ( -
- - {action === 'crop' && ( -
- - - - -
- )} - {action === 'crop' && ( -
- - -
- )} -
- ); - }; -} diff --git a/packages/feedback/src/screenshot/components/CropCorner.tsx b/packages/feedback/src/screenshot/components/CropCorner.tsx deleted file mode 100644 index de3b6e506e71..000000000000 --- a/packages/feedback/src/screenshot/components/CropCorner.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function CropCornerFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function CropCorner({ - top, - left, - corner, - onGrabButton, - }: { - top: number; - left: number; - corner: string; - onGrabButton: (e: Event, corner: string) => void; - }): VNode { - return ( - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/CropIcon.tsx b/packages/feedback/src/screenshot/components/CropIcon.tsx deleted file mode 100644 index 091179d86004..000000000000 --- a/packages/feedback/src/screenshot/components/CropIcon.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function CropIconFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function CropIcon(): VNode { - return ( - - - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/IconClose.tsx b/packages/feedback/src/screenshot/components/IconClose.tsx new file mode 100644 index 000000000000..dea383a61839 --- /dev/null +++ b/packages/feedback/src/screenshot/components/IconClose.tsx @@ -0,0 +1,29 @@ +import type { VNode, h as hType } from 'preact'; + +interface FactoryParams { + h: typeof hType; +} + +export default function IconCloseFactory({ + h, // eslint-disable-line @typescript-eslint/no-unused-vars +}: FactoryParams) { + return function IconClose(): VNode { + return ( + + + + + + + ); + }; +} diff --git a/packages/feedback/src/screenshot/components/PenIcon.tsx b/packages/feedback/src/screenshot/components/PenIcon.tsx deleted file mode 100644 index 75a0faedf480..000000000000 --- a/packages/feedback/src/screenshot/components/PenIcon.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { VNode, h as hType } from 'preact'; - -interface FactoryParams { - h: typeof hType; -} - -export default function PenIconFactory({ - h, // eslint-disable-line @typescript-eslint/no-unused-vars -}: FactoryParams) { - return function PenIcon(): VNode { - return ( - - - - - - ); - }; -} diff --git a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx index 9f49abf60e6f..d6a257c056dd 100644 --- a/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx +++ b/packages/feedback/src/screenshot/components/ScreenshotEditor.tsx @@ -1,17 +1,15 @@ +/* eslint-disable max-lines */ import type { FeedbackInternalOptions, FeedbackModalIntegration } from '@sentry/core'; import type { ComponentType, VNode, h as hType } from 'preact'; -// biome-ignore lint/nursery/noUnusedImports: reason +// biome-ignore lint/nursery/noUnusedImports: need Preact import for JSX import { h } from 'preact'; // eslint-disable-line @typescript-eslint/no-unused-vars import type * as Hooks from 'preact/hooks'; -import { WINDOW } from '../../constants'; -import AnnotationsFactory from './Annotations'; -import CropFactory from './Crop'; +import { DOCUMENT, WINDOW } from '../../constants'; +import IconCloseFactory from './IconClose'; import { createScreenshotInputStyles } from './ScreenshotInput.css'; import ToolbarFactory from './Toolbar'; import { useTakeScreenshotFactory } from './useTakeScreenshot'; -const DPI = WINDOW.devicePixelRatio; - interface FactoryParams { h: typeof hType; hooks: typeof Hooks; @@ -24,6 +22,8 @@ interface Props { onError: (error: Error) => void; } +type Action = 'highlight' | 'hide'; + interface Box { startX: number; startY: number; @@ -31,17 +31,31 @@ interface Box { endY: number; } -interface Rect { +interface Dimensions { x: number; y: number; height: number; width: number; } -const getContainedSize = (img: HTMLCanvasElement): Rect => { - const imgClientHeight = img.clientHeight; - const imgClientWidth = img.clientWidth; - const ratio = img.width / img.height; +interface Rect extends Dimensions { + action: Action; +} + +const DPI = WINDOW.devicePixelRatio; + +const constructRect = (action: Action, box: Box): Rect => ({ + action, + x: Math.min(box.startX, box.endX), + y: Math.min(box.startY, box.endY), + width: Math.abs(box.startX - box.endX), + height: Math.abs(box.startY - box.endY), +}); + +const getContainedSize = (measurementDiv: HTMLDivElement, imageSource: HTMLCanvasElement): Dimensions => { + const imgClientHeight = measurementDiv.clientHeight; + const imgClientWidth = measurementDiv.clientWidth; + const ratio = imageSource.width / imageSource.height; let width = imgClientHeight * ratio; let height = imgClientHeight; if (width > imgClientWidth) { @@ -53,6 +67,53 @@ const getContainedSize = (img: HTMLCanvasElement): Rect => { return { x: x, y: y, width: width, height: height }; }; +function drawRect(rect: Rect, ctx: CanvasRenderingContext2D, color: string, scale: number = 1): void { + const scaledX = rect.x * scale; + const scaledY = rect.y * scale; + const scaledWidth = rect.width * scale; + const scaledHeight = rect.height * scale; + + switch (rect.action) { + case 'highlight': { + // creates a shadow around + ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; + ctx.shadowBlur = 50; + + // draws a rectangle first so that the shadow is visible before clearing + ctx.fillStyle = 'rgb(0, 0, 0)'; + ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); + ctx.clearRect(scaledX, scaledY, scaledWidth, scaledHeight); + + // Disable shadow after the action is drawn + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + + ctx.strokeStyle = color; + ctx.strokeRect(scaledX + 1, scaledY + 1, scaledWidth - 2, scaledHeight - 2); + + break; + } + case 'hide': + ctx.fillStyle = 'rgb(0, 0, 0)'; + ctx.fillRect(scaledX, scaledY, scaledWidth, scaledHeight); + + break; + default: + break; + } +} + +function resizeCanvas(canvas: HTMLCanvasElement, imageDimensions: Dimensions): void { + canvas.width = imageDimensions.width * DPI; + canvas.height = imageDimensions.height * DPI; + canvas.style.width = `${imageDimensions.width}px`; + canvas.style.height = `${imageDimensions.height}px`; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(DPI, DPI); + } +} + export function ScreenshotEditorFactory({ h, hooks, @@ -62,23 +123,73 @@ export function ScreenshotEditorFactory({ }: FactoryParams): ComponentType { const useTakeScreenshot = useTakeScreenshotFactory({ hooks }); const Toolbar = ToolbarFactory({ h }); - const Annotations = AnnotationsFactory({ h }); - const Crop = CropFactory({ h, hooks, options }); + const IconClose = IconCloseFactory({ h }); + const styles = { __html: createScreenshotInputStyles(options.styleNonce).innerText }; return function ScreenshotEditor({ onError }: Props): VNode { - const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []); + // Data for rendering: + const [action, setAction] = hooks.useState('highlight'); + const [drawRects, setDrawRects] = hooks.useState([]); + const [currentRect, setCurrentRect] = hooks.useState(undefined); - const canvasContainerRef = hooks.useRef(null); - const cropContainerRef = hooks.useRef(null); + // Refs to our html components: + const measurementRef = hooks.useRef(null); + const screenshotRef = hooks.useRef(null); const annotatingRef = hooks.useRef(null); - const croppingRef = hooks.useRef(null); - const [action, setAction] = hooks.useState<'annotate' | 'crop' | ''>('crop'); - const [croppingRect, setCroppingRect] = hooks.useState({ - startX: 0, - startY: 0, - endX: 0, - endY: 0, - }); + const rectContainerRef = hooks.useRef(null); + + // The canvas that contains the original screenshot + const [imageSource, setImageSource] = hooks.useState(null); + + // Hide the whole feedback widget when we take the screenshot + const [displayEditor, setDisplayEditor] = hooks.useState(true); + + // The size of our window, relative to the imageSource + const [scaleFactor, setScaleFactor] = hooks.useState(1); + + const strokeColor = hooks.useMemo((): string => { + const sentryFeedback = DOCUMENT.getElementById(options.id); + if (!sentryFeedback) { + return 'white'; + } + const computedStyle = getComputedStyle(sentryFeedback); + return ( + computedStyle.getPropertyValue('--button-primary-background') || + computedStyle.getPropertyValue('--accent-background') + ); + }, [options.id]); + + const resize = hooks.useCallback((): void => { + if (!displayEditor) { + return; + } + + const screenshotCanvas = screenshotRef.current; + const annotatingCanvas = annotatingRef.current; + const measurementDiv = measurementRef.current; + const rectContainer = rectContainerRef.current; + if (!screenshotCanvas || !annotatingCanvas || !imageSource || !measurementDiv || !rectContainer) { + return; + } + + const imageDimensions = getContainedSize(measurementDiv, imageSource); + + resizeCanvas(screenshotCanvas, imageDimensions); + resizeCanvas(annotatingCanvas, imageDimensions); + + rectContainer.style.width = `${imageDimensions.width}px`; + rectContainer.style.height = `${imageDimensions.height}px`; + + const scale = annotatingCanvas.clientWidth / imageBuffer.width; + setScaleFactor(scale); + + const screenshotContext = screenshotCanvas.getContext('2d', { alpha: false }); + if (!screenshotContext) { + return; + } + screenshotContext.drawImage(imageSource, 0, 0, imageDimensions.width, imageDimensions.height); + drawScene(); + }, [imageSource, drawRects, displayEditor]); hooks.useEffect(() => { WINDOW.addEventListener('resize', resize); @@ -86,87 +197,192 @@ export function ScreenshotEditorFactory({ return () => { WINDOW.removeEventListener('resize', resize); }; - }, []); + }, [resize]); + + hooks.useLayoutEffect(() => { + resize(); + }, [resize]); + + hooks.useEffect(() => { + drawScene(); + drawBuffer(); + }, [drawRects]); - function resizeCanvas(canvasRef: Hooks.Ref, imageDimensions: Rect): void { - const canvas = canvasRef.current; - if (!canvas) { + hooks.useEffect(() => { + if (currentRect) { + drawScene(); + } + }, [currentRect]); + + // draws the commands onto the imageBuffer, which is what's sent to Sentry + const drawBuffer = hooks.useCallback((): void => { + const ctx = imageBuffer.getContext('2d', { alpha: false }); + const measurementDiv = measurementRef.current; + if (!imageBuffer || !ctx || !imageSource || !measurementDiv) { return; } - canvas.width = imageDimensions.width * DPI; - canvas.height = imageDimensions.height * DPI; - canvas.style.width = `${imageDimensions.width}px`; - canvas.style.height = `${imageDimensions.height}px`; - const ctx = canvas.getContext('2d'); - if (ctx) { - ctx.scale(DPI, DPI); + ctx.drawImage(imageSource, 0, 0); + + const annotatingBufferBig = DOCUMENT.createElement('canvas'); + annotatingBufferBig.width = imageBuffer.width; + annotatingBufferBig.height = imageBuffer.height; + + const grayCtx = annotatingBufferBig.getContext('2d'); + if (!grayCtx) { + return; } - } - function resize(): void { - const imageDimensions = getContainedSize(imageBuffer); + // applies the graywash if there's any boxes drawn + if (drawRects.length || currentRect) { + grayCtx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + grayCtx.fillRect(0, 0, imageBuffer.width, imageBuffer.height); + } - resizeCanvas(croppingRef, imageDimensions); - resizeCanvas(annotatingRef, imageDimensions); + grayCtx.lineWidth = 4; + drawRects.forEach(rect => { + drawRect(rect, grayCtx, strokeColor); + }); + ctx.drawImage(annotatingBufferBig, 0, 0); + }, [drawRects, strokeColor]); - const cropContainer = cropContainerRef.current; - if (cropContainer) { - cropContainer.style.width = `${imageDimensions.width}px`; - cropContainer.style.height = `${imageDimensions.height}px`; + const drawScene = hooks.useCallback((): void => { + const annotatingCanvas = annotatingRef.current; + if (!annotatingCanvas) { + return; } - setCroppingRect({ startX: 0, startY: 0, endX: imageDimensions.width, endY: imageDimensions.height }); - } + const ctx = annotatingCanvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, annotatingCanvas.width, annotatingCanvas.height); + + // applies the graywash if there's any boxes drawn + if (drawRects.length || currentRect) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.fillRect(0, 0, annotatingCanvas.width, annotatingCanvas.height); + } + + ctx.lineWidth = 2; + const scale = annotatingCanvas.clientWidth / imageBuffer.width; + drawRects.forEach(rect => { + drawRect(rect, ctx, strokeColor, scale); + }); + + if (currentRect) { + drawRect(currentRect, ctx, strokeColor); + } + }, [drawRects, currentRect, strokeColor]); useTakeScreenshot({ onBeforeScreenshot: hooks.useCallback(() => { (dialog.el as HTMLElement).style.display = 'none'; + setDisplayEditor(false); + }, []), + onScreenshot: hooks.useCallback((imageSource: HTMLVideoElement) => { + const bufferCanvas = DOCUMENT.createElement('canvas'); + bufferCanvas.width = imageSource.videoWidth; + bufferCanvas.height = imageSource.videoHeight; + bufferCanvas.getContext('2d', { alpha: false })?.drawImage(imageSource, 0, 0); + setImageSource(bufferCanvas); + + imageBuffer.width = imageSource.videoWidth; + imageBuffer.height = imageSource.videoHeight; }, []), - onScreenshot: hooks.useCallback( - (imageSource: HTMLVideoElement) => { - const context = imageBuffer.getContext('2d'); - if (!context) { - throw new Error('Could not get canvas context'); - } - imageBuffer.width = imageSource.videoWidth; - imageBuffer.height = imageSource.videoHeight; - imageBuffer.style.width = '100%'; - imageBuffer.style.height = '100%'; - context.drawImage(imageSource, 0, 0); - }, - [imageBuffer], - ), onAfterScreenshot: hooks.useCallback(() => { (dialog.el as HTMLElement).style.display = 'block'; - const container = canvasContainerRef.current; - container?.appendChild(imageBuffer); - resize(); + setDisplayEditor(true); }, []), onError: hooks.useCallback(error => { (dialog.el as HTMLElement).style.display = 'block'; + setDisplayEditor(true); onError(error); }, []), }); + const handleMouseDown = (e: MouseEvent): void => { + const annotatingCanvas = annotatingRef.current; + if (!action || !annotatingCanvas) { + return; + } + + const boundingRect = annotatingCanvas.getBoundingClientRect(); + + const startX = e.clientX - boundingRect.left; + const startY = e.clientY - boundingRect.top; + + const handleMouseMove = (e: MouseEvent): void => { + const endX = e.clientX - boundingRect.left; + const endY = e.clientY - boundingRect.top; + const rect = constructRect(action, { startX, startY, endX, endY }); + // prevent drawing when just clicking (not dragging) on the canvas + if (startX != endX && startY != endY) { + setCurrentRect(rect); + } + }; + + const handleMouseUp = (e: MouseEvent): void => { + // no rect is being drawn anymore, so setting active rect to undefined + setCurrentRect(undefined); + const endX = Math.max(0, Math.min(e.clientX - boundingRect.left, annotatingCanvas.width / DPI)); + const endY = Math.max(0, Math.min(e.clientY - boundingRect.top, annotatingCanvas.height / DPI)); + // prevent drawing a rect when just clicking (not dragging) on the canvas (ie. clicking delete) + if (startX != endX && startY != endY) { + // scale to image buffer + const scale = imageBuffer.width / annotatingCanvas.clientWidth; + const rect = constructRect(action, { + startX: startX * scale, + startY: startY * scale, + endX: endX * scale, + endY: endY * scale, + }); + setDrawRects(prev => [...prev, rect]); + } + + DOCUMENT.removeEventListener('mousemove', handleMouseMove); + DOCUMENT.removeEventListener('mouseup', handleMouseUp); + }; + + DOCUMENT.addEventListener('mousemove', handleMouseMove); + DOCUMENT.addEventListener('mouseup', handleMouseUp); + }; + + const handleDeleteRect = (index: number): void => { + const updatedRects = [...drawRects]; + updatedRects.splice(index, 1); + setDrawRects(updatedRects); + }; + return (