From 6b914d892c676dd547e58e8229aebb53c52691a0 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 3 Sep 2026 14:35:00 +0200 Subject: [PATCH 1/2] feat(op): Add span op deprecation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Span ops are shipped to SDKs as generated constants, so retiring one needs a deprecation path instead of a deletion. Ops can now carry an optional `deprecation` object with a `replacement` and a `reason`. Deprecated ops keep their constant and get a JSDoc `@deprecated` tag in JavaScript and a `#[deprecated]` attribute in Rust, plus a badge and a replacement notice on the docs site. Kept intentionally light: unlike attribute deprecations there is no `_status`, since this only concerns SDKs and not the ingestion pipeline. No op is deprecated yet — this only adds the mechanism. Fixes GH-619 Co-Authored-By: Claude --- CONTRIBUTING.md | 10 +++ docs/src/content.config.ts | 6 ++ docs/src/pages/ops/index.astro | 25 ++++++- schemas/op.schema.json | 15 +++++ scripts/generate_op.ts | 106 +++++++++++++++++++++++------ scripts/types.ts | 15 +++++ test/op.test.ts | 118 +++++++++++++++++++++++++++++++++ 7 files changed, 271 insertions(+), 24 deletions(-) create mode 100644 test/op.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b1c92fc1..99a96c0c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,16 @@ Here's a list of policies that any newly added attributes MUST follow. Most of t - If the value cannot be copied directly to the replacement attribute, use `_status: "transform"` and reference an attribute transformation with `deprecation.transformation`. - Prefer keeping names stable. Renames require deprecation cycles across all SDKs that adopted the attribute! +### Span operations + +Span ops live in `model/op/` and are shipped to SDKs as generated constants, so they can't just be removed. + +- Deprecate an op instead of deleting it, by adding a `deprecation` object to its field in `model/op/.json`. +- Point at the successor with `deprecation.replacement` whenever there is one. It MUST be an existing, non-deprecated op. +- Add a `deprecation.reason` if the replacement alone doesn't explain the change. +- If the op is listed in more than one category, all of its definitions MUST declare the same `deprecation`, because they share a single generated constant. +- Run `yarn run generate` afterwards. Deprecated ops keep their constant, marked with a JSDoc `@deprecated` tag in JavaScript and `#[deprecated]` in Rust. + ## Testing This repo uses [Vitest](https://vitest.dev/) for testing. To run the tests, run `yarn test`. diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts index 038e7aaa8..56c859c81 100644 --- a/docs/src/content.config.ts +++ b/docs/src/content.config.ts @@ -109,6 +109,12 @@ const descriptions = defineCollection({ const opFieldSchema = z.object({ name: z.string(), description: z.string().optional(), + deprecation: z + .object({ + replacement: z.string().optional(), + reason: z.string().optional(), + }) + .optional(), }); const opSchema = z.object({ diff --git a/docs/src/pages/ops/index.astro b/docs/src/pages/ops/index.astro index 704f9e4ca..4926b0ef2 100644 --- a/docs/src/pages/ops/index.astro +++ b/docs/src/pages/ops/index.astro @@ -90,13 +90,32 @@ const totalOps = allOps.reduce((acc, op) => acc + op.data.fields.length, 0); {op.data.fields.map(field => ( - - {field.name} + + + {field.name} + {field.deprecation && ( + Deprecated + )} + {field.description ? ( - ) : ( + ) : !field.deprecation ? ( + ) : null} + {field.deprecation && ( +
+

+ {field.deprecation.replacement ? ( + Use {field.deprecation.replacement} instead. + ) : ( + 'No replacement available at this time.' + )} +

+ {field.deprecation.reason && ( +

{field.deprecation.reason}

+ )} +
)} diff --git a/schemas/op.schema.json b/schemas/op.schema.json index 18b161c5a..7ed5b25df 100644 --- a/schemas/op.schema.json +++ b/schemas/op.schema.json @@ -31,6 +31,21 @@ }, "description": { "type": "string" + }, + "deprecation": { + "description": "If a span op was deprecated, and what it was replaced with. Deprecated ops keep their generated constant, which is marked as deprecated for SDKs.", + "type": "object", + "additionalProperties": false, + "properties": { + "replacement": { + "description": "The span op to use instead. Must be an op defined in model/op/", + "type": "string" + }, + "reason": { + "description": "Why the op was deprecated", + "type": "string" + } + } } }, "required": ["name"], diff --git a/scripts/generate_op.ts b/scripts/generate_op.ts index 485a33faf..7b729f50b 100644 --- a/scripts/generate_op.ts +++ b/scripts/generate_op.ts @@ -1,34 +1,40 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import type { OpFieldJson, OpJson } from './types'; -interface OpField { - name: string; - description?: string; +interface GenerateOpsOptions { + opDir: string; + jsOutputFilePath: string; + rustOutputFilePath: string; } -interface OpCategory { +interface OpCategory extends OpJson { file: string; - name: string; - description?: string; - fields: OpField[]; } -export async function generateOps() { - const opDir = path.join(__dirname, '..', 'model', 'op'); +type OpDeprecation = NonNullable; + +export async function generateOps(options?: Partial) { + const repositoryRoot = path.join(__dirname, '..'); + const opDir = options?.opDir ?? path.join(repositoryRoot, 'model', 'op'); + const jsOutputFilePath = + options?.jsOutputFilePath ?? path.join(repositoryRoot, 'javascript', 'sentry-conventions', 'src', 'op.ts'); + const rustOutputFilePath = options?.rustOutputFilePath ?? path.join(repositoryRoot, 'rust', 'src', 'op.rs'); const opFiles = await fs.promises.readdir(opDir); const categories = readCategories(opDir, opFiles); const owners = resolveConstantOwners(categories); + const deprecations = resolveDeprecations(categories); - writeToJs(categories, owners); - writeToRust(categories, owners); + writeToJs(categories, owners, deprecations, jsOutputFilePath); + writeToRust(categories, owners, deprecations, rustOutputFilePath); } function readCategories(opDir: string, opFiles: string[]): OpCategory[] { // Sort for deterministic output: the file order decides both the order of the emitted blocks and, // for ops defined in multiple categories without a description, which category owns the constant. return [...opFiles].sort().map((file) => { - const opJson = JSON.parse(fs.readFileSync(path.join(opDir, file), 'utf-8')); + const opJson = JSON.parse(fs.readFileSync(path.join(opDir, file), 'utf-8')) as OpJson; return { file, name: opJson.name, description: opJson.description, fields: opJson.fields }; }); } @@ -59,12 +65,50 @@ function resolveConstantOwners(categories: OpCategory[]): Map { return new Map([...owners].map(([name, { file }]) => [name, file])); } +/** + * An op defined in multiple categories only yields one constant, so its deprecation is looked up by + * op name rather than per definition. Every definition of an op must declare the same deprecation + * (enforced by the test suite), which makes the first one found authoritative. + * + * Returns a map of op name -> deprecation, for deprecated ops only. + */ +function resolveDeprecations(categories: OpCategory[]): Map { + const deprecations = new Map(); + + for (const category of categories) { + for (const field of category.fields) { + if (field.deprecation && !deprecations.has(field.name)) { + deprecations.set(field.name, field.deprecation); + } + } + } + + return deprecations; +} + /** The fields of `category` whose constant is emitted in this category. */ -function ownedFields(category: OpCategory, owners: Map): OpField[] { +function ownedFields(category: OpCategory, owners: Map): OpFieldJson[] { return category.fields.filter((field) => owners.get(field.name) === category.file); } -function writeToRust(categories: OpCategory[], owners: Map) { +/** The `Use X instead - reason` part of a deprecation notice, with the replacement constant linked as `link`. */ +function deprecationNote(deprecation: OpDeprecation, link: (replacement: string) => string): string { + const parts: string[] = []; + if (deprecation.replacement) { + parts.push(`Use ${link(deprecation.replacement)} (${deprecation.replacement}) instead`); + } + if (deprecation.reason) { + parts.push(deprecation.reason); + } + return parts.join(' - '); +} + +function writeToRust( + categories: OpCategory[], + owners: Map, + deprecations: Map, + opFilePath: string, +) { let opContent = '// This is an auto-generated file. Do not edit!\n\n'; for (const category of categories) { @@ -86,6 +130,11 @@ function writeToRust(categories: OpCategory[], owners: Map) { opContent += field.description.split('\n').join('\n/// '); opContent += '\n'; } + const deprecation = deprecations.get(field.name); + if (deprecation) { + const note = deprecationNote(deprecation, (replacement) => `\`${constantName(replacement)}\``); + opContent += note ? `#[deprecated(note = "${escapeRustString(note)}")]\n` : '#[deprecated]\n'; + } opContent += `pub const ${constantName(field.name)}: &str = "${field.name}";\n\n`; } } @@ -93,12 +142,19 @@ function writeToRust(categories: OpCategory[], owners: Map) { // Remove the trailing newline character opContent = opContent.trimEnd(); - const opFilePath = path.join(__dirname, '..', 'rust', 'src', 'op.rs'); - fs.writeFileSync(opFilePath, opContent); } -function writeToJs(categories: OpCategory[], owners: Map) { +function escapeRustString(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); +} + +function writeToJs( + categories: OpCategory[], + owners: Map, + deprecations: Map, + opFilePath: string, +) { let opContent = '// This is an auto-generated file. Do not edit!\n'; for (const category of categories) { @@ -115,9 +171,19 @@ function writeToJs(categories: OpCategory[], owners: Map) { } for (const field of fields) { - if (field.description) { + const deprecation = deprecations.get(field.name); + if (field.description || deprecation) { opContent += '\n/**\n'; - opContent += ` * ${field.description}\n`; + if (field.description) { + opContent += ` * ${field.description}\n`; + } + if (deprecation) { + if (field.description) { + opContent += ' *\n'; + } + const note = deprecationNote(deprecation, (replacement) => `{@link ${constantName(replacement)}}`); + opContent += ` * @deprecated${note ? ` ${note}` : ''}\n`; + } opContent += ' */\n'; } else { opContent += '\n'; @@ -126,7 +192,5 @@ function writeToJs(categories: OpCategory[], owners: Map) { } } - const opFilePath = path.join(__dirname, '..', 'javascript', 'sentry-conventions', 'src', 'op.ts'); - fs.writeFileSync(opFilePath, opContent); } diff --git a/scripts/types.ts b/scripts/types.ts index 7b25c3d45..b908dda44 100644 --- a/scripts/types.ts +++ b/scripts/types.ts @@ -53,6 +53,21 @@ export interface DescriptionJson { }[]; } +export interface OpFieldJson { + name: string; + description?: string; + deprecation?: { + replacement?: string; + reason?: string; + }; +} + +export interface OpJson { + name: string; + description?: string; + fields: OpFieldJson[]; +} + export interface AttributeTransformationJson { id: string; brief: string; diff --git a/test/op.test.ts b/test/op.test.ts new file mode 100644 index 000000000..d8f53231b --- /dev/null +++ b/test/op.test.ts @@ -0,0 +1,118 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import Ajv from 'ajv'; +import { describe, expect, it } from 'vitest'; + +import schema from '../schemas/op.schema.json'; +import { generateOps } from '../scripts/generate_op'; +import type { OpJson } from '../scripts/types'; + +const opsFolder = path.resolve(__dirname, '../model/op'); + +describe('Op JSON', async () => { + const filesIterator = fs.promises.glob(`${opsFolder}/*.json`); + const files = await Array.fromAsync(filesIterator); + const ajv = new Ajv(); + + const categories = await Promise.all( + files.map(async (file) => ({ + file: path.basename(file), + content: JSON.parse(await fs.promises.readFile(file, 'utf-8')) as OpJson, + })), + ); + + const allFields = categories.flatMap(({ content }) => content.fields); + const opNames = new Set(allFields.map((field) => field.name)); + const deprecatedOpNames = new Set(allFields.filter((field) => field.deprecation).map((field) => field.name)); + + for (const { file, content } of categories) { + describe(file, () => { + it('should follow the op json schema', () => { + ajv.validate(schema, content); + expect(ajv.errors).toBe(null); + }); + + it('should not have duplicate ops', () => { + const names = content.fields.map((field) => field.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('should only deprecate ops in favor of an existing, non-deprecated op', () => { + for (const field of content.fields) { + const replacement = field.deprecation?.replacement; + if (replacement === undefined) { + continue; + } + + expect(replacement, `${field.name} cannot replace itself`).not.toBe(field.name); + expect([...opNames], `replacement "${replacement}" of "${field.name}" must exist`).toContain(replacement); + expect( + [...deprecatedOpNames], + `replacement "${replacement}" of "${field.name}" must not be deprecated`, + ).not.toContain(replacement); + } + }); + }); + } + + // Ops defined in multiple categories (e.g. `http`) share a single generated constant, so the + // deprecation cannot differ between their definitions. + it('should declare the same deprecation for every definition of an op', () => { + const deprecationsByOp = new Map(); + + for (const field of allFields) { + const deprecations = deprecationsByOp.get(field.name) ?? []; + deprecations.push(JSON.stringify(field.deprecation ?? null)); + deprecationsByOp.set(field.name, deprecations); + } + + for (const [name, deprecations] of deprecationsByOp) { + expect(new Set(deprecations).size, `definitions of "${name}" declare different deprecations`).toBe(1); + } + }); +}); + +describe('generateOps', () => { + it('marks deprecated ops in the generated JavaScript and Rust constants', async () => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-conventions-')); + const opDir = path.join(temporaryDirectory, 'op'); + const jsOutputFilePath = path.join(temporaryDirectory, 'op.ts'); + const rustOutputFilePath = path.join(temporaryDirectory, 'op.rs'); + fs.mkdirSync(opDir); + fs.writeFileSync( + path.join(opDir, 'test.json'), + JSON.stringify({ + name: 'test', + fields: [ + { name: 'test.new', description: 'The replacement op.' }, + { + name: 'test.old', + description: 'The deprecated op.', + deprecation: { replacement: 'test.new', reason: 'Renamed for consistency' }, + }, + { name: 'test.gone', deprecation: {} }, + ], + }), + ); + + try { + await generateOps({ opDir, jsOutputFilePath, rustOutputFilePath }); + + const javascript = fs.readFileSync(jsOutputFilePath, 'utf8'); + expect(javascript).toContain( + ' * @deprecated Use {@link TEST_NEW} (test.new) instead - Renamed for consistency\n', + ); + expect(javascript).toContain(" * @deprecated\n */\nexport const TEST_GONE = 'test.gone';"); + expect(javascript).toContain("export const TEST_OLD = 'test.old';"); + + const rust = fs.readFileSync(rustOutputFilePath, 'utf8'); + expect(rust).toContain('#[deprecated(note = "Use `TEST_NEW` (test.new) instead - Renamed for consistency")]\n'); + expect(rust).toContain('#[deprecated]\npub const TEST_GONE: &str = "test.gone";'); + expect(rust).toContain('/// The replacement op.\npub const TEST_NEW: &str = "test.new";'); + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + }); +}); From 975e2c5d45ba104fb2da917633e0937a21710712 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 3 Sep 2026 14:59:54 +0200 Subject: [PATCH 2/2] feat(docs): Make the op deprecation notice compact and linkable Follow-up on the ops page rendering: the notice took up three times the height of a regular row and its reason was hard to read in both themes. - Link the replacement to the row that documents it. - Drop the padded box for a single compact line. - Read the reason in the regular secondary text color, at full opacity. Refs GH-619 Co-Authored-By: Claude --- docs/src/pages/ops/index.astro | 35 +++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/src/pages/ops/index.astro b/docs/src/pages/ops/index.astro index 4926b0ef2..382ad57e7 100644 --- a/docs/src/pages/ops/index.astro +++ b/docs/src/pages/ops/index.astro @@ -18,6 +18,19 @@ allOps.sort((a, b) => { // Count total operations across all categories const totalOps = allOps.reduce((acc, op) => acc + op.data.fields.length, 0); + +// An op can be listed in several categories, so anchors are scoped to their category. +const opAnchor = (categoryId: string, opName: string) => `${categoryId}-${opName.replace(/\./g, '-')}`; + +// Where a deprecation notice links to: the first listing of the replacement op. +const anchorByOp = new Map(); +for (const category of allOps) { + for (const field of category.data.fields) { + if (!anchorByOp.has(field.name)) { + anchorByOp.set(field.name, opAnchor(category.id, field.name)); + } + } +} --- acc + op.data.fields.length, 0); {op.data.fields.map(field => ( - + - {field.name} + {field.name} {field.deprecation && ( Deprecated )} @@ -104,17 +117,21 @@ const totalOps = allOps.reduce((acc, op) => acc + op.data.fields.length, 0); ) : null} {field.deprecation && ( -
-

+

+ {field.deprecation.replacement ? ( - Use {field.deprecation.replacement} instead. + Use{' '} + {anchorByOp.has(field.deprecation.replacement) ? ( + {field.deprecation.replacement} + ) : ( + {field.deprecation.replacement} + )} instead. + ) : ( 'No replacement available at this time.' )} -

- {field.deprecation.reason && ( -

{field.deprecation.reason}

- )} +
+ {field.deprecation.reason && {' '}{field.deprecation.reason}}
)}