Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<category>.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`.
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
42 changes: 39 additions & 3 deletions docs/src/pages/ops/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
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));
}
}
}
---

<BaseLayout
Expand Down Expand Up @@ -90,13 +103,36 @@ const totalOps = allOps.reduce((acc, op) => acc + op.data.fields.length, 0);
</thead>
<tbody>
{op.data.fields.map(field => (
<tr>
<td class="whitespace-nowrap align-top"><code class="text-xs">{field.name}</code></td>
<tr id={opAnchor(op.id, field.name)} class="scroll-mt-20">
<td class="whitespace-nowrap align-top">
<code class:list={['text-xs', { 'line-through text-text-muted': !!field.deprecation }]}>{field.name}</code>
{field.deprecation && (
<span class="badge badge-deprecated ml-2">Deprecated</span>
)}
</td>
<td class="text-text-secondary">
{field.description ? (
<span set:html={renderMarkdownLinks(field.description)} />
) : (
) : !field.deprecation ? (
<span class="text-text-muted">—</span>
) : null}
{field.deprecation && (
<div class:list={['text-sm pl-2 border-l-2 border-error', { 'mt-2': !!field.description }]}>
<span class="text-text-primary">
{field.deprecation.replacement ? (
<Fragment>Use{' '}
{anchorByOp.has(field.deprecation.replacement) ? (
<a href={`#${anchorByOp.get(field.deprecation.replacement)}`} class="text-accent hover:text-accent-hover"><code class="text-xs">{field.deprecation.replacement}</code></a>
) : (
<code class="text-xs">{field.deprecation.replacement}</code>
)} instead.
</Fragment>
) : (
'No replacement available at this time.'
)}
</span>
{field.deprecation.reason && <span class="text-text-secondary">{' '}{field.deprecation.reason}</span>}
</div>
)}
</td>
</tr>
Expand Down
15 changes: 15 additions & 0 deletions schemas/op.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
106 changes: 85 additions & 21 deletions scripts/generate_op.ts
Original file line number Diff line number Diff line change
@@ -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<OpFieldJson['deprecation']>;

export async function generateOps(options?: Partial<GenerateOpsOptions>) {
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 };
});
}
Expand Down Expand Up @@ -59,12 +65,50 @@ function resolveConstantOwners(categories: OpCategory[]): Map<string, string> {
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<string, OpDeprecation> {
const deprecations = new Map<string, OpDeprecation>();

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<string, string>): OpField[] {
function ownedFields(category: OpCategory, owners: Map<string, string>): OpFieldJson[] {
return category.fields.filter((field) => owners.get(field.name) === category.file);
}

function writeToRust(categories: OpCategory[], owners: Map<string, string>) {
/** 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<string, string>,
deprecations: Map<string, OpDeprecation>,
opFilePath: string,
) {
let opContent = '// This is an auto-generated file. Do not edit!\n\n';

for (const category of categories) {
Expand All @@ -86,19 +130,31 @@ function writeToRust(categories: OpCategory[], owners: Map<string, string>) {
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`;
}
}

// 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<string, string>) {
function escapeRustString(value: string): string {
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
}

function writeToJs(
categories: OpCategory[],
owners: Map<string, string>,
deprecations: Map<string, OpDeprecation>,
opFilePath: string,
) {
let opContent = '// This is an auto-generated file. Do not edit!\n';

for (const category of categories) {
Expand All @@ -115,9 +171,19 @@ function writeToJs(categories: OpCategory[], owners: Map<string, string>) {
}

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';
Expand All @@ -126,7 +192,5 @@ function writeToJs(categories: OpCategory[], owners: Map<string, string>) {
}
}

const opFilePath = path.join(__dirname, '..', 'javascript', 'sentry-conventions', 'src', 'op.ts');

fs.writeFileSync(opFilePath, opContent);
}
15 changes: 15 additions & 0 deletions scripts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading