Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/strict-sdk-usage-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@redocly/client-generator': minor
'@redocly/cli': minor
---

Changed the usage telemetry event of the generated clients to the CloudEvent schema of the other Redocly CLI events.
2 changes: 2 additions & 0 deletions docs/@v2/commands/generate-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
const created = await createOrder({ body: { items: [{ menuItemId: 'itm_1', quantity: 2 }] } });
```

The client has no dependencies, and it carries the behavior an API needs: auth for every scheme the description declares, opt-in retries, timeouts, middleware, pagination iterators, and typed server-sent events.

Check warning on line 28 in docs/@v2/commands/generate-client.md

View workflow job for this annotation

GitHub Actions / recheck

technical-english/sentence-length

Sentence is 30 words long; ASD-STE100 recommends at most 20 words in procedures and 25 in descriptive text (max 25).
Add a flag for each extra artifact you want:

```bash
Expand Down Expand Up @@ -128,7 +128,7 @@
- `single` (default): the command writes one file.
The file is self-contained with the default `inline` runtime.
- `split`: the command writes two files.
It puts the schema types and the type guards in a sibling file, `<name>.schemas.ts`. The entry file re-exports them.

Check warning on line 131 in docs/@v2/commands/generate-client.md

View workflow job for this annotation

GitHub Actions / recheck

recheck/semantic-line-breaks

Use semantic line breaks (sentence mode).
Because of this, your imports are the same as in `single`.

```bash
Expand All @@ -139,7 +139,7 @@

The `--runtime` flag controls where the client engine lives:

- `inline` (default): the engine is embedded in the generated file, so the client is one self-contained file.

Check notice on line 142 in docs/@v2/commands/generate-client.md

View workflow job for this annotation

GitHub Actions / recheck

technical-english/passive-voice

Prefer the active voice; ASD-STE100 recommends it ("is embedded").
- `module`: the command writes the engine as real files in a `runtime/` folder beside the client, and the client imports them relatively.
Several generated clients in one repository can share one `runtime/` folder, and you can read the engine as ordinary source files.
The files are still machine-owned: the command regenerates them on every run.
Expand Down Expand Up @@ -212,7 +212,7 @@

Go has no exit hook, so call `Flush(ctx)` before your program ends.

To count the calls of every client of the package, for example in a published SDK, call `telemetry.Register()` in an `init` function instead of passing the middleware.

Check warning on line 215 in docs/@v2/commands/generate-client.md

View workflow job for this annotation

GitHub Actions / recheck

technical-english/sentence-length

Sentence is 27 words long; ASD-STE100 recommends at most 20 words in procedures and 25 in descriptive text (max 25).
Then call `client.FlushUsageTelemetry(ctx)` before your program ends.

{% /tab %}
Expand Down Expand Up @@ -252,6 +252,8 @@
By default, the events go to the Redocly OpenTelemetry endpoint.
Set `endpoint` to send them to another OTLP/HTTP traces URL, for example a local collector.
Pass a `sink` function to receive the events yourself instead.
Each event is a CloudEvent of type `com.redocly.sdkUsage.aggregated`.
Its `data` holds two items: the operation with its counts, then the SDK.

If the connection fails or the endpoint answers `408`, `429`, or `5xx`, the module adds the counts to the next window.
After a timeout or any other failed send, it drops the counts.
Expand Down Expand Up @@ -280,7 +282,7 @@
redocly generate-client openapi.yaml -o src/api/client.ts --report generate-client-report.json
```

The command writes the file when the run succeeds. It has one entry for each API:

Check warning on line 285 in docs/@v2/commands/generate-client.md

View workflow job for this annotation

GitHub Actions / recheck

recheck/semantic-line-breaks

Use semantic line breaks (sentence mode).

```json
{
Expand Down
21 changes: 6 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-trace-node": "2.8.0",
"@opentelemetry/semantic-conventions": "1.41.1",
"@redocly/cli-otel": "0.3.7",
"@redocly/cli-otel": "0.4.0",
"@redocly/client-generator": "0.9.3",
"@redocly/openapi-core": "2.76.0",
"@redocly/recheck": "2.76.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ADR 0024: The usage telemetry event follows the CloudEvent schema of @redocly/cli-otel

- Status: Accepted
- Date: 2026-11-05
- Amends: [ADR-0023](./0023-usage-telemetry-across-languages.md) (point 3)

## Context

ADR 0023 gave every SDK language one usage event, but only the TypeScript module defined its shape.
Redocly telemetry events follow one CloudEvent structure: `data` is a list of resources, each with `id`, `object`, and `uri`, and `source`, `origin`, and the event ID have fixed formats.
`@redocly/cli-otel` holds the types and the OpenTelemetry mapper that come from the AsyncAPI description of these events, and the CLI already sends its `com.redocly.command.ran` event through them.
The SDK usage event did not follow that structure: its `data` was an object with a `usage` and an `sdk` map.
A generated SDK has no dependencies, so it cannot import the package.

## Decision

1. **The event is the `com.redocly.sdkUsage.aggregated` CloudEvent of `@redocly/cli-otel`.**
`data` holds the operation with its counts, then the SDK.
`source` is `urn:redocly:cli`, `origin` is `redocly-sdk`, and the ID is `evt_` and a lowercase ULID.
The module does not send `env`: the OpenTelemetry collector sets it.
2. **The attributes of each span are what `processCloudEventAttributes` of `@redocly/cli-otel` gives for the event.**
3. **The TypeScript module types its event with the message type of the package.**
The prepare script replaces the type import with the declarations, so the generated module still has no dependencies, and strict `tsc` on a generated client checks the module against the schema.
4. **The Go, Python, and PHP modules build the same event by hand.**
The end-to-end test of each language compares every span attribute with the mapper output for the expected event.

## Consequences

- A change to the event starts in the AsyncAPI description.
When a new `@redocly/cli-otel` changes the event, the type check or the end-to-end tests fail until the four modules follow.
- The attribute keys changed: the counts moved from `cloudevents.event_data.usage.*` to `cloudevents.event_data.operation.*`, and `cloudevents.event_data.sdk.name` became `cloudevents.event_data.sdk.id`.
A query on the old keys reads only the events of SDKs generated before this change.
- Point 3 of ADR 0023 changes: the `uri` fields hold the language, so they differ between languages too, beside `sdk.language`, `sdk.runtime`, and `sdk.runtime_version`.
- A custom `sink` receives the CloudEvent, so a sink that read `data.usage` must read the first item of `data`.
- The generated TypeScript module carries the type declarations of the event, about 190 lines.
- The type import names a file inside the package, because the package root does not export the message types.
The root also re-exports through paths without an extension, so under `nodenext` its types are `any`; `skipLibCheck`, which this repository turns on, hides the error.
3 changes: 2 additions & 1 deletion packages/client-generator/docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ ARCHITECTURE.md says _what is_; these ADRs say _why_.
| [0020](./0020-self-contained-generator-folders.md) | Self-contained generator folders, ejected as source | Accepted |
| [0021](./0021-text-printers.md) | Text printers — one common printer plus one per language | Accepted |
| [0022](./0022-runtime-inline-or-module.md) | Runtime is inline or a sibling module; package mode removed | Accepted |
| [0023](./0023-usage-telemetry-across-languages.md) | One usage telemetry event for every SDK language, through a settled hook | Accepted |
| [0023](./0023-usage-telemetry-across-languages.md) | One usage telemetry event for every SDK language, through a settled hook | Amended by 0024 |
| [0024](./0024-usage-event-follows-cli-otel-schema.md) | The usage telemetry event follows the CloudEvent schema of cli-otel | Accepted |

## Template

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ these files). Naming and string escaping live in the TypeScript printer
(`@redocly/client-generator/printers/typescript`).
The telemetry middleware ships the same way, as `TELEMETRY_RUNTIME_SOURCE` (in this repo
it lives in `telemetry/`); `index.ts` writes it with the contract import pointed at the
client entry and the generator version baked in.
client entry and the generator version baked in. The prepare script replaces its type import
from `@redocly/cli-otel` with the declarations of the event type (ADR 0024).

## Ejecting it

Expand Down
1 change: 1 addition & 0 deletions packages/client-generator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
}
},
"devDependencies": {
"@redocly/cli-otel": "0.4.0",
"typescript": "6.0.2"
},
"files": [
Expand Down
43 changes: 28 additions & 15 deletions packages/client-generator/scripts/generate-runtime-sources.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
Expand Down Expand Up @@ -42,11 +43,15 @@ const CONTRACT_MODULES = {
'../../../cli-contract.js': join(pkgRoot, 'src', 'cli-contract.ts'),
};

/** The declaration's start including its own doc comment, excluding detached trivia. */
/**
* The declaration's start including its own doc comment, excluding detached trivia and the
* file header comment.
*/
function declStartWithDocs(source, declaration) {
const ranges = ts.getLeadingCommentRanges(source, declaration.getFullStart()) ?? [];
let start = declaration.getStart();
for (let index = ranges.length - 1; index >= 0; index--) {
if (ranges[index].pos === 0) break;
if (/\n\s*\n/.test(source.slice(ranges[index].end, start))) break;
start = ranges[index].pos;
}
Expand All @@ -61,7 +66,9 @@ function contractDeclarationsText(modulePath, names) {
const parts = [];
for (const statement of file.statements) {
const named =
(ts.isTypeAliasDeclaration(statement) || ts.isFunctionDeclaration(statement)) &&
(ts.isTypeAliasDeclaration(statement) ||
ts.isInterfaceDeclaration(statement) ||
ts.isFunctionDeclaration(statement)) &&
statement.name !== undefined;
if (named && wanted.has(statement.name.text)) {
parts.push(source.slice(declStartWithDocs(source, statement), statement.end));
Expand All @@ -75,15 +82,15 @@ function contractDeclarationsText(modulePath, names) {
}

/**
* Replace the runtime module's contract imports/re-exports with the definitions they
* point at, so every downstream use (full source, stripped embed, declared names) sees
* one self-contained module.
* Replace the module's imports/re-exports of `modules` with the definitions they point at,
* so every downstream use (full source, stripped embed, declared names) sees one
* self-contained module.
*/
function spliceContracts(source) {
function spliceContracts(source, modules = CONTRACT_MODULES) {
const file = ts.createSourceFile('__splice.ts', source, ts.ScriptTarget.Latest, true);
const edits = [];
for (const statement of file.statements) {
if (ts.isImportDeclaration(statement) && CONTRACT_MODULES[statement.moduleSpecifier.text]) {
if (ts.isImportDeclaration(statement) && modules[statement.moduleSpecifier.text]) {
// Delete the import line and its trailing newlines only — the module's header
// comment is this statement's leading trivia and must survive.
let end = statement.end;
Expand All @@ -92,13 +99,10 @@ function spliceContracts(source) {
} else if (
ts.isExportDeclaration(statement) &&
statement.moduleSpecifier !== undefined &&
CONTRACT_MODULES[statement.moduleSpecifier.text]
modules[statement.moduleSpecifier.text]
) {
const names = statement.exportClause.elements.map((element) => element.name.text);
const block = contractDeclarationsText(
CONTRACT_MODULES[statement.moduleSpecifier.text],
names
);
const block = contractDeclarationsText(modules[statement.moduleSpecifier.text], names);
edits.push({ start: statement.getFullStart(), end: statement.end, text: `\n\n${block}` });
}
}
Expand Down Expand Up @@ -212,10 +216,19 @@ writeFileSync(
].join('\n')
);

const EVENT_TYPE_MODULE =
'@redocly/cli-otel/lib/generated-async-redocly-cli/messages/SdkUsageAggregatedMessage.js';
// Not in RUNTIME_SOURCES, so `telemetry` and `flush` never become reserved names of every client.
const telemetrySource = readFileSync(
join(pkgRoot, 'src', 'generators', 'typescript', 'telemetry', 'telemetry.ts'),
'utf-8'
const telemetrySource = spliceContracts(
readFileSync(
join(pkgRoot, 'src', 'generators', 'typescript', 'telemetry', 'telemetry.ts'),
'utf-8'
),
{
[EVENT_TYPE_MODULE]: createRequire(import.meta.url).resolve(
EVENT_TYPE_MODULE.replace(/\.js$/, '.d.ts')
),
}
);
writeFileSync(
join(pkgRoot, 'src', 'runtime-sources', 'telemetry.ts'),
Expand Down
Loading
Loading