diff --git a/javascript/sentry-conventions/src/op.ts b/javascript/sentry-conventions/src/op.ts index a6d4c3c4..92d3daee 100644 --- a/javascript/sentry-conventions/src/op.ts +++ b/javascript/sentry-conventions/src/op.ts @@ -141,6 +141,9 @@ export const UI_TASK = 'ui.task'; */ export const UI_RESOLVE = 'ui.resolve'; +/** + * Rendering of a UI component. + */ export const UI_RENDER = 'ui.render'; /** @@ -198,26 +201,59 @@ export const UI_WEBVITAL_LCP = 'ui.webvital.lcp'; */ export const UI_WEBVITAL_CLS = 'ui.webvital.cls'; +/** + * A user interaction that starts a new trace segment and collects the work it triggers. + */ export const UI_ACTION = 'ui.action'; +/** + * A click that starts a new idle span, collecting work the interaction triggers. + */ export const UI_ACTION_CLICK = 'ui.action.click'; +/** + * Legacy React UI operation. Prefer ui.mount, ui.render, and ui.update. + */ export const UI_REACT = 'ui.react'; +/** + * Legacy React component mount. Prefer ui.mount. + */ export const UI_REACT_MOUNT = 'ui.react.mount'; +/** + * Legacy React component render. Prefer ui.render. + */ export const UI_REACT_RENDER = 'ui.react.render'; +/** + * Legacy React component update. Prefer ui.update. + */ export const UI_REACT_UPDATE = 'ui.react.update'; +/** + * Legacy Vue UI operation. Prefer ui.mount, ui.render, ui.update, and ui.unmount. + */ export const UI_VUE = 'ui.vue'; +/** + * Legacy Svelte UI operation. Prefer ui.mount and ui.update. + */ export const UI_SVELTE = 'ui.svelte'; +/** + * Legacy Angular UI operation. Prefer ui.mount. + */ export const UI_ANGULAR = 'ui.angular'; +/** + * Legacy Ember UI operation. Prefer ui.mount, ui.render, ui.task, and ui.resolve. + */ export const UI_EMBER = 'ui.ember'; +/** + * Legacy Livewire UI operation. Prefer ui.mount, ui.render, and ui.update. + */ export const UI_LIVEWIRE = 'ui.livewire'; // Path: model/op/database.json diff --git a/model/description/ui.json b/model/description/ui.json new file mode 100644 index 00000000..4c2f9368 --- /dev/null +++ b/model/description/ui.json @@ -0,0 +1,53 @@ +{ + "brief": "UI", + "operations": [ + { + "name": "UI component", + "brief": "Mounting, updating, rendering, unmounting, or resolving a UI component.", + "ops": ["ui.mount", "ui.update", "ui.render", "ui.unmount", "ui.resolve", "ui"], + "templates": ["{{ui.component_name}}"], + "examples": ["HomeButton", "UserProfile"] + }, + { + "name": "Main UI thread blocked", + "brief": "A long task or long animation frame on the main UI thread.", + "ops": ["ui.long_task", "ui.long_animation_frame"], + "templates": ["{{code.function.name}}", "{{browser.script.invoker}}"], + "examples": ["handleClick", "Window.requestAnimationFrame"] + }, + { + "name": "UI action", + "brief": "A user interaction that starts a new trace segment, described by the route it occurred on.", + "ops": ["ui.action", "ui.action.click"], + "templates": [ + "{{router.navigation.route.id}}", + "{{url.template}}", + "{{http.route}}", + "{{url.path}}", + "{{url.full}}" + ], + "examples": ["UserProfile", "/users/:id", "/users/123", "http://example.com/users/123"] + }, + { + "name": "UI interaction", + "brief": "A user interaction measured via Interaction to Next Paint (INP) or the Event Timing API, described by the element the user interacted with.", + "ops": ["ui.interaction.click", "ui.interaction.hover", "ui.interaction.drag", "ui.interaction.press"], + "templates": ["{{browser.web_vital.inp.target}}", "{{ui.component_name}}"], + "examples": ["body > div#app > button.submit", "HomeButton"] + }, + { + "name": "Largest contentful paint", + "brief": "A Largest Contentful Paint (LCP) web vital measurement.", + "ops": ["ui.webvital.lcp"], + "templates": ["{{browser.web_vital.lcp.element}}", "{{ui.component_name}}"], + "examples": ["body > div#app > div#container > div", "HeroImage"] + }, + { + "name": "Layout shift", + "brief": "A Cumulative Layout Shift (CLS) web vital measurement, described by the first of its layout shift sources.", + "ops": ["ui.webvital.cls"], + "templates": ["{{browser.web_vital.cls.source.1}}", "{{ui.component_name}}"], + "examples": ["body > div#app", "HeroImage"] + } + ] +} diff --git a/model/name/ui.json b/model/name/ui.json new file mode 100644 index 00000000..f2d626c3 --- /dev/null +++ b/model/name/ui.json @@ -0,0 +1,133 @@ +{ + "brief": "UI", + "operations": [ + { + "name": "Component mount", + "brief": "Mounting of a UI component or application (e.g. initial render/bootstrap).", + "is_in_otel": false, + "ops": ["ui.mount"], + "templates": ["{{ui.component_name}}", "Component mount"], + "examples": ["HomeButton", "Component mount"] + }, + { + "name": "Component update", + "brief": "Updating of an already-mounted UI component.", + "is_in_otel": false, + "ops": ["ui.update"], + "templates": ["{{ui.component_name}}", "Component update"], + "examples": ["HomeButton", "Component update"] + }, + { + "name": "Component render", + "brief": "Rendering of a UI component.", + "is_in_otel": false, + "ops": ["ui.render"], + "templates": ["{{ui.component_name}}", "Component render"], + "examples": ["HomeButton", "Component render"] + }, + { + "name": "Component unmount", + "brief": "Unmounting/teardown of a UI component.", + "is_in_otel": false, + "ops": ["ui.unmount"], + "templates": ["{{ui.component_name}}", "Component unmount"], + "examples": ["HomeButton", "Component unmount"] + }, + { + "name": "Component resolve", + "brief": "Looking up which component a name in a template refers to, before that component renders.", + "is_in_otel": false, + "ops": ["ui.resolve"], + "templates": ["{{ui.component_name}}", "Component resolve"], + "examples": ["HomeButton", "Component resolve"] + }, + { + "name": "UI task", + "brief": "A task taken on the main UI thread, such as an Ember runloop queue.", + "is_in_otel": false, + "ops": ["ui.task"], + "templates": ["UI task"], + "examples": ["UI task"] + }, + { + "name": "Main UI thread blocked", + "brief": "A long task or long animation frame on the main UI thread.", + "is_in_otel": false, + "ops": ["ui.long_task", "ui.long_animation_frame"], + "templates": ["Main UI thread blocked"], + "examples": ["Main UI thread blocked"] + }, + { + "name": "UI action", + "brief": "A user interaction that starts a new trace segment and collects the work it triggers.", + "is_in_otel": false, + "ops": ["ui.action"], + "templates": ["{{router.navigation.route.id}}", "{{url.template}}", "{{http.route}}", "UI action"], + "examples": ["UserProfile", "/users/:id", "/home", "UI action"] + }, + { + "name": "Click action", + "brief": "A click that starts a new idle span, named after the current route.", + "is_in_otel": false, + "ops": ["ui.action.click"], + "templates": ["{{router.navigation.route.id}}", "{{url.template}}", "{{http.route}}", "Click"], + "examples": ["UserProfile", "/users/:id", "/home", "Click"] + }, + { + "name": "Click interaction", + "brief": "A click interaction measured via Interaction to Next Paint (INP) or the Event Timing API.", + "is_in_otel": false, + "ops": ["ui.interaction.click"], + "templates": ["{{ui.component_name}}", "Click"], + "examples": ["HomeButton", "Click"] + }, + { + "name": "Hover interaction", + "brief": "A hover interaction measured via Interaction to Next Paint (INP).", + "is_in_otel": false, + "ops": ["ui.interaction.hover"], + "templates": ["{{ui.component_name}}", "Hover"], + "examples": ["HomeButton", "Hover"] + }, + { + "name": "Drag interaction", + "brief": "A drag interaction measured via Interaction to Next Paint (INP).", + "is_in_otel": false, + "ops": ["ui.interaction.drag"], + "templates": ["{{ui.component_name}}", "Drag"], + "examples": ["HomeButton", "Drag"] + }, + { + "name": "Key press interaction", + "brief": "A key press interaction measured via Interaction to Next Paint (INP).", + "is_in_otel": false, + "ops": ["ui.interaction.press"], + "templates": ["{{ui.component_name}}", "Key press"], + "examples": ["SearchInput", "Key press"] + }, + { + "name": "Largest contentful paint", + "brief": "A Largest Contentful Paint (LCP) web vital measurement.", + "is_in_otel": false, + "ops": ["ui.webvital.lcp"], + "templates": ["{{ui.component_name}}", "Largest contentful paint"], + "examples": ["HeroImage", "Largest contentful paint"] + }, + { + "name": "Layout shift", + "brief": "A Cumulative Layout Shift (CLS) web vital measurement.", + "is_in_otel": false, + "ops": ["ui.webvital.cls"], + "templates": ["{{ui.component_name}}", "Layout shift"], + "examples": ["HeroImage", "Layout shift"] + }, + { + "name": "UI", + "brief": "A generic UI operation. Prefer the more specific ui.mount, ui.render, ui.update, and ui.unmount ops.", + "is_in_otel": false, + "ops": ["ui"], + "templates": ["{{ui.component_name}}", "UI"], + "examples": ["HomeButton", "UI"] + } + ] +} diff --git a/model/op/browser.json b/model/op/browser.json index bf339036..18ae4148 100644 --- a/model/op/browser.json +++ b/model/op/browser.json @@ -126,7 +126,8 @@ "description": "Looking up which component a name in a template refers to, before that component renders. Ember does this for every component it renders. The render itself is a separate `ui.render` span." }, { - "name": "ui.render" + "name": "ui.render", + "description": "Rendering of a UI component." }, { "name": "ui.mount", @@ -173,37 +174,48 @@ "description": "A Cumulative Layout Shift (CLS) web vital measurement." }, { - "name": "ui.action" + "name": "ui.action", + "description": "A user interaction that starts a new trace segment and collects the work it triggers." }, { - "name": "ui.action.click" + "name": "ui.action.click", + "description": "A click that starts a new idle span, collecting work the interaction triggers." }, { - "name": "ui.react" + "name": "ui.react", + "description": "Legacy React UI operation. Prefer ui.mount, ui.render, and ui.update." }, { - "name": "ui.react.mount" + "name": "ui.react.mount", + "description": "Legacy React component mount. Prefer ui.mount." }, { - "name": "ui.react.render" + "name": "ui.react.render", + "description": "Legacy React component render. Prefer ui.render." }, { - "name": "ui.react.update" + "name": "ui.react.update", + "description": "Legacy React component update. Prefer ui.update." }, { - "name": "ui.vue" + "name": "ui.vue", + "description": "Legacy Vue UI operation. Prefer ui.mount, ui.render, ui.update, and ui.unmount." }, { - "name": "ui.svelte" + "name": "ui.svelte", + "description": "Legacy Svelte UI operation. Prefer ui.mount and ui.update." }, { - "name": "ui.angular" + "name": "ui.angular", + "description": "Legacy Angular UI operation. Prefer ui.mount." }, { - "name": "ui.ember" + "name": "ui.ember", + "description": "Legacy Ember UI operation. Prefer ui.mount, ui.render, ui.task, and ui.resolve." }, { - "name": "ui.livewire" + "name": "ui.livewire", + "description": "Legacy Livewire UI operation. Prefer ui.mount, ui.render, and ui.update." } ] } diff --git a/model/op/mobile.json b/model/op/mobile.json index 85d00e08..35543f3f 100644 --- a/model/op/mobile.json +++ b/model/op/mobile.json @@ -14,7 +14,8 @@ "description": "The time it takes to download an over-the-air (OTA) update." }, { - "name": "ui" + "name": "ui", + "description": "Operations related to mobile UI." }, { "name": "navigation" diff --git a/rust/src/op.rs b/rust/src/op.rs index 1ea9a20e..8974b6af 100644 --- a/rust/src/op.rs +++ b/rust/src/op.rs @@ -93,6 +93,7 @@ pub const UI_TASK: &str = "ui.task"; /// Looking up which component a name in a template refers to, before that component renders. Ember does this for every component it renders. The render itself is a separate `ui.render` span. pub const UI_RESOLVE: &str = "ui.resolve"; +/// Rendering of a UI component. pub const UI_RENDER: &str = "ui.render"; /// Mounting of a UI component or application (e.g. initial render/bootstrap). @@ -128,26 +129,37 @@ pub const UI_WEBVITAL_LCP: &str = "ui.webvital.lcp"; /// A Cumulative Layout Shift (CLS) web vital measurement. pub const UI_WEBVITAL_CLS: &str = "ui.webvital.cls"; +/// A user interaction that starts a new trace segment and collects the work it triggers. pub const UI_ACTION: &str = "ui.action"; +/// A click that starts a new idle span, collecting work the interaction triggers. pub const UI_ACTION_CLICK: &str = "ui.action.click"; +/// Legacy React UI operation. Prefer ui.mount, ui.render, and ui.update. pub const UI_REACT: &str = "ui.react"; +/// Legacy React component mount. Prefer ui.mount. pub const UI_REACT_MOUNT: &str = "ui.react.mount"; +/// Legacy React component render. Prefer ui.render. pub const UI_REACT_RENDER: &str = "ui.react.render"; +/// Legacy React component update. Prefer ui.update. pub const UI_REACT_UPDATE: &str = "ui.react.update"; +/// Legacy Vue UI operation. Prefer ui.mount, ui.render, ui.update, and ui.unmount. pub const UI_VUE: &str = "ui.vue"; +/// Legacy Svelte UI operation. Prefer ui.mount and ui.update. pub const UI_SVELTE: &str = "ui.svelte"; +/// Legacy Angular UI operation. Prefer ui.mount. pub const UI_ANGULAR: &str = "ui.angular"; +/// Legacy Ember UI operation. Prefer ui.mount, ui.render, ui.task, and ui.resolve. pub const UI_EMBER: &str = "ui.ember"; +/// Legacy Livewire UI operation. Prefer ui.mount, ui.render, and ui.update. pub const UI_LIVEWIRE: &str = "ui.livewire"; // Path: model/op/database.json diff --git a/scripts/utils.ts b/scripts/utils.ts index a929e89a..616b2ce1 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -8,3 +8,14 @@ export function fileNameToAttributeKey(fileName: string): string { .replaceAll('__', '.') .replace('[key]', ''); } + +/** + * Returns the dynamic-suffix key that a concrete attribute key could be an instance of, + * e.g. `browser.web_vital.cls.source.1` -> `browser.web_vital.cls.source.`. + * + * Returns undefined for keys without a separator, which cannot carry a suffix. + */ +export function attributeKeyToDynamicSuffixKey(key: string): string | undefined { + const lastSeparator = key.lastIndexOf('.'); + return lastSeparator === -1 ? undefined : `${key.slice(0, lastSeparator)}.`; +} diff --git a/test/description.test.ts b/test/description.test.ts index 37f85542..9b7b08b1 100644 --- a/test/description.test.ts +++ b/test/description.test.ts @@ -5,11 +5,10 @@ import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; import schema from '../schemas/description.schema.json'; -import type { AttributeJson, DescriptionJson } from '../scripts/types'; -import { attributeKeyToFileName } from '../scripts/utils'; +import type { DescriptionJson } from '../scripts/types'; +import { findTemplateAttributeIssues } from './template-attributes'; const descriptionsFolder = path.resolve(__dirname, '../model/description'); -const attributesFolder = path.resolve(__dirname, '../model/attributes'); describe('Description JSON', async () => { const filesIterator = fs.promises.glob(`${descriptionsFolder}/*.json`); @@ -42,37 +41,7 @@ describe('Description JSON', async () => { }); it('only references existing, non-replaced attributes', async () => { - const placeholder = /\{\{([^}]+)\}\}/g; - const missing: string[] = []; - const deprecated: string[] = []; - - for (const operation of content.operations) { - for (const tmpl of operation.templates) { - for (const match of tmpl.matchAll(placeholder)) { - const key = match[1] as string; - const fileName = attributeKeyToFileName(key); - const namespace = key.includes('.') ? (key.split('.')[0] as string) : undefined; - const filePath = namespace - ? path.join(attributesFolder, namespace, fileName) - : path.join(attributesFolder, fileName); - - const exists = await fs.promises - .access(filePath, fs.constants.F_OK) - .then(() => true) - .catch(() => false); - - if (!exists) { - missing.push(key); - continue; - } - - const attr: AttributeJson = JSON.parse(await fs.promises.readFile(filePath, 'utf-8')); - if (attr.deprecation?.replacement) { - deprecated.push(key); - } - } - } - } + const { missing, deprecated } = await findTemplateAttributeIssues(content); expect(missing, `template attributes without definitions: ${missing.join(', ')}`).toEqual([]); expect( diff --git a/test/name.test.ts b/test/name.test.ts index f3fb4f4d..5e898feb 100644 --- a/test/name.test.ts +++ b/test/name.test.ts @@ -5,11 +5,10 @@ import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; import schema from '../schemas/name.schema.json'; -import type { AttributeJson, NameJson } from '../scripts/types'; -import { attributeKeyToFileName } from '../scripts/utils'; +import type { NameJson } from '../scripts/types'; +import { findTemplateAttributeIssues } from './template-attributes'; const namesFolder = path.resolve(__dirname, '../model/name'); -const attributesFolder = path.resolve(__dirname, '../model/attributes'); describe('Name JSON', async () => { const filesIterator = fs.promises.glob(`${namesFolder}/*.json`); @@ -46,37 +45,7 @@ describe('Name JSON', async () => { }); it('only references existing, non-replaced attributes', async () => { - const placeholder = /\{\{([^}]+)\}\}/g; - const missing: string[] = []; - const deprecated: string[] = []; - - for (const operation of content.operations) { - for (const tmpl of operation.templates) { - for (const match of tmpl.matchAll(placeholder)) { - const key = match[1] as string; - const fileName = attributeKeyToFileName(key); - const namespace = key.includes('.') ? (key.split('.')[0] as string) : undefined; - const filePath = namespace - ? path.join(attributesFolder, namespace, fileName) - : path.join(attributesFolder, fileName); - - const exists = await fs.promises - .access(filePath, fs.constants.F_OK) - .then(() => true) - .catch(() => false); - - if (!exists) { - missing.push(key); - continue; - } - - const attr: AttributeJson = JSON.parse(await fs.promises.readFile(filePath, 'utf-8')); - if (attr.deprecation?.replacement) { - deprecated.push(key); - } - } - } - } + const { missing, deprecated } = await findTemplateAttributeIssues(content); expect(missing, `template attributes without definitions: ${missing.join(', ')}`).toEqual([]); expect( diff --git a/test/template-attributes.test.ts b/test/template-attributes.test.ts new file mode 100644 index 00000000..b8b8eb20 --- /dev/null +++ b/test/template-attributes.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { attributeKeyToDynamicSuffixKey } from '../scripts/utils'; +import { findTemplateAttributeIssues, resolveTemplateAttribute } from './template-attributes'; + +describe('attributeKeyToDynamicSuffixKey', () => { + it('replaces the last segment with the dynamic suffix', () => { + expect(attributeKeyToDynamicSuffixKey('browser.web_vital.cls.source.1')).toBe('browser.web_vital.cls.source.'); + }); + + it('returns undefined for keys that cannot carry a suffix', () => { + expect(attributeKeyToDynamicSuffixKey('inp')).toBeUndefined(); + }); +}); + +describe('resolveTemplateAttribute', () => { + it('resolves an attribute named in full', async () => { + await expect(resolveTemplateAttribute('ui.component_name')).resolves.toMatchObject({ + key: 'ui.component_name', + }); + }); + + it('resolves a concrete variant of an attribute with a dynamic suffix', async () => { + await expect(resolveTemplateAttribute('browser.web_vital.cls.source.1')).resolves.toMatchObject({ + key: 'browser.web_vital.cls.source.', + }); + }); + + it('does not resolve a suffixed variant of an attribute without a dynamic suffix', async () => { + await expect(resolveTemplateAttribute('ui.component_name.1')).resolves.toBeUndefined(); + }); + + it('does not resolve an unknown attribute', async () => { + await expect(resolveTemplateAttribute('does.not.exist')).resolves.toBeUndefined(); + }); +}); + +describe('findTemplateAttributeIssues', () => { + const operation = (templates: string[]) => ({ operations: [{ templates }] }); + + it('reports placeholders that do not resolve', async () => { + const { missing, deprecated } = await findTemplateAttributeIssues(operation(['{{does.not.exist}}'])); + + expect(missing).toEqual(['does.not.exist']); + expect(deprecated).toEqual([]); + }); + + it('reports a concrete variant of a deprecated attribute with a dynamic suffix', async () => { + // `cls.source.` is deprecated in favour of `browser.web_vital.cls.source.` + const { missing, deprecated } = await findTemplateAttributeIssues(operation(['{{cls.source.1}}'])); + + expect(missing).toEqual([]); + expect(deprecated).toEqual(['cls.source.1']); + }); + + it('accepts a mix of full and concrete dynamic-suffix keys', async () => { + const issues = await findTemplateAttributeIssues( + operation(['{{browser.web_vital.cls.source.1}}', '{{ui.component_name}}']), + ); + + expect(issues).toEqual({ missing: [], deprecated: [] }); + }); +}); diff --git a/test/template-attributes.ts b/test/template-attributes.ts new file mode 100644 index 00000000..dc0b1441 --- /dev/null +++ b/test/template-attributes.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { AttributeJson } from '../scripts/types'; +import { attributeKeyToDynamicSuffixKey, attributeKeyToFileName } from '../scripts/utils'; + +const attributesFolder = path.resolve(__dirname, '../model/attributes'); +const placeholder = /\{\{([^}]+)\}\}/g; + +async function readAttribute(key: string): Promise { + const fileName = attributeKeyToFileName(key); + const namespace = key.includes('.') ? (key.split('.')[0] as string) : undefined; + const filePath = namespace ? path.join(attributesFolder, namespace, fileName) : path.join(attributesFolder, fileName); + + return fs.promises + .readFile(filePath, 'utf-8') + .then((content) => JSON.parse(content) as AttributeJson) + .catch(() => undefined); +} + +/** + * Resolves the attribute a template placeholder refers to. + * + * A placeholder may name a concrete variant of an attribute with a dynamic suffix, e.g. + * `browser.web_vital.cls.source.1` for `browser.web_vital.cls.source.`, for templates + * that need one specific value rather than the whole set. Only attributes that declare + * `has_dynamic_suffix` can be named that way. + */ +export async function resolveTemplateAttribute(key: string): Promise { + const attribute = await readAttribute(key); + if (attribute) { + return attribute; + } + + const dynamicSuffixKey = attributeKeyToDynamicSuffixKey(key); + if (!dynamicSuffixKey) { + return undefined; + } + + const dynamicSuffixAttribute = await readAttribute(dynamicSuffixKey); + return dynamicSuffixAttribute?.has_dynamic_suffix ? dynamicSuffixAttribute : undefined; +} + +/** + * Collects the placeholders across a name or description file that don't resolve to an + * attribute, and those that resolve to one that has been deprecated in favour of another. + */ +export async function findTemplateAttributeIssues(content: { + operations: { templates: string[] }[]; +}): Promise<{ missing: string[]; deprecated: string[] }> { + const missing: string[] = []; + const deprecated: string[] = []; + + for (const operation of content.operations) { + for (const tmpl of operation.templates) { + for (const match of tmpl.matchAll(placeholder)) { + const key = match[1] as string; + const attribute = await resolveTemplateAttribute(key); + + if (!attribute) { + missing.push(key); + } else if (attribute.deprecation?.replacement) { + deprecated.push(key); + } + } + } + } + + return { missing, deprecated }; +}