diff --git a/.changeset/strict-sdk-usage-event.md b/.changeset/strict-sdk-usage-event.md new file mode 100644 index 0000000000..041bdfe4b7 --- /dev/null +++ b/.changeset/strict-sdk-usage-event.md @@ -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. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index 35c3624510..727d89bbf5 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -252,6 +252,8 @@ Then `UsageTelemetry::flushRegistered()` sends the open windows, for example bef 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. diff --git a/package-lock.json b/package-lock.json index e532935e29..89a6f644c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3303,23 +3303,13 @@ "link": true }, "node_modules/@redocly/cli-otel": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/@redocly/cli-otel/-/cli-otel-0.3.7.tgz", - "integrity": "sha512-hpAKY7RrzWV+cra9VLJ0WS9dE0K24e56IJyFABBrFt2q0o/Rt8e+3Ag0E16IQTM0E+UkZfXCfdYSYs0bWEYg6Q==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@redocly/cli-otel/-/cli-otel-0.4.0.tgz", + "integrity": "sha512-cZbG/5fCwMRBPok15M1uUMlAyASPbS7OB7KZxa0ittMLYnSND3a8ERpF0KRiwAt+BIIfUPmZtyP1gB9bkn2zyg==", "dev": true, "license": "MIT", "dependencies": { - "ulid": "^2.3.0" - } - }, - "node_modules/@redocly/cli-otel/node_modules/ulid": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz", - "integrity": "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==", - "dev": true, - "license": "MIT", - "bin": { - "ulid": "bin/cli.js" + "ulid": "^3.0.2" } }, "node_modules/@redocly/client-generator": { @@ -12576,7 +12566,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/platform-integration": "2.76.0", @@ -12652,6 +12642,7 @@ "@redocly/openapi-core": "2.76.0" }, "devDependencies": { + "@redocly/cli-otel": "0.4.0", "typescript": "6.0.2" }, "engines": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3b2cb38fe1..0f161f5bf9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/client-generator/docs/adr/0024-usage-event-follows-cli-otel-schema.md b/packages/client-generator/docs/adr/0024-usage-event-follows-cli-otel-schema.md new file mode 100644 index 0000000000..68146d6ead --- /dev/null +++ b/packages/client-generator/docs/adr/0024-usage-event-follows-cli-otel-schema.md @@ -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. diff --git a/packages/client-generator/docs/adr/README.md b/packages/client-generator/docs/adr/README.md index 3457f2c7b6..de01542d7b 100644 --- a/packages/client-generator/docs/adr/README.md +++ b/packages/client-generator/docs/adr/README.md @@ -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 diff --git a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md index e9ff634ba5..dcec3cca77 100644 --- a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md @@ -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 diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index 96c94fd38d..a3326348b5 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -98,6 +98,7 @@ } }, "devDependencies": { + "@redocly/cli-otel": "0.4.0", "typescript": "6.0.2" }, "files": [ diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index f1b8d501df..df1780073c 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -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'; @@ -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; } @@ -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)); @@ -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; @@ -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}` }); } } @@ -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'), diff --git a/packages/client-generator/src/generators/go/telemetry/telemetry.go b/packages/client-generator/src/generators/go/telemetry/telemetry.go index 68ad8a251b..9f8528144a 100644 --- a/packages/client-generator/src/generators/go/telemetry/telemetry.go +++ b/packages/client-generator/src/generators/go/telemetry/telemetry.go @@ -28,6 +28,8 @@ const ( usageTelemetryEventType = "com.redocly.sdkUsage.aggregated" usageTelemetryServiceName = "redocly-sdk" usageTelemetrySendTimeout = 3 * time.Second + usageTelemetrySDKURNPrefix = "urn:redocly:cli:sdk:go:" + usageTelemetryCrockfordBase32 = "0123456789abcdefghjkmnpqrstvwxyz" ) // usageTelemetryRegistered holds each UsageTelemetry that Register added. @@ -57,15 +59,23 @@ type UsageTelemetryOptions struct { FlushInterval time.Duration } -// UsageTelemetryEvent is the counts of one operation in one window. Usage values -// are strings or int64; Time is the window start in RFC 3339 with milliseconds. +// UsageTelemetryEvent is one com.redocly.sdkUsage.aggregated CloudEvent: the counts +// of one operation in one window. Data holds the operation, then the SDK; their values +// are strings or int64. Time is the window start in RFC 3339 with milliseconds. The +// OpenTelemetry collector sets the env of the event. The json tags make a Sink that +// marshals an event write the CloudEvent field names, as in the other languages. type UsageTelemetryEvent struct { - ID string - Type string - Source string - Time string - Usage map[string]any - SDK map[string]string + ID string `json:"id"` + Object string `json:"object"` + SpecVersion string `json:"specversion"` + DataContentType string `json:"datacontenttype"` + Type string `json:"type"` + Source string `json:"source"` + Time string `json:"time"` + Origin string `json:"origin"` + Category string `json:"category"` + Signal string `json:"signal"` + Data []map[string]any `json:"data"` } type usageTelemetryCounts struct { @@ -80,7 +90,7 @@ type usageTelemetryCounts struct { // its Middleware(). It is safe for concurrent use. type UsageTelemetry struct { options UsageTelemetryOptions - sdk map[string]string + sdk map[string]any mu sync.Mutex counts map[string]*usageTelemetryCounts windowStart time.Time @@ -99,8 +109,10 @@ func NewUsageTelemetry(options UsageTelemetryOptions) *UsageTelemetry { } return &UsageTelemetry{ options: options, - sdk: map[string]string{ - "name": options.SDKName, + sdk: map[string]any{ + "id": options.SDKName, + "object": "sdk", + "uri": usageTelemetrySDKURNPrefix + options.SDKName, "version": options.SDKVersion, "generator_version": usageTelemetryGeneratorVersion, "language": "go", @@ -264,31 +276,55 @@ func usageTelemetryStatusClass(call SettledCall) string { func (t *UsageTelemetry) toEvents(counts map[string]*usageTelemetryCounts, windowStart time.Time, window time.Duration) []UsageTelemetryEvent { events := []UsageTelemetryEvent{} for operationID, operation := range counts { - usage := map[string]any{ - "operation_id": operationID, + operationItem := map[string]any{ + "id": operationID, + "object": "operation", + "uri": usageTelemetrySDKURNPrefix + t.options.SDKName + ":operation:" + operationID, "window_ms": window.Milliseconds(), "calls": operation.calls, "attempts": operation.attempts, "duration_sum_ms": operation.durationSumMs, } for key, count := range operation.status { - usage["status_"+key] = count + operationItem["status_"+key] = count } for key, count := range operation.latency { - usage["latency_"+key] = count + operationItem["latency_"+key] = count } events = append(events, UsageTelemetryEvent{ - ID: "evt_" + usageTelemetryRandomHex(16), - Type: usageTelemetryEventType, - Source: t.options.SDKName, - Time: windowStart.UTC().Format("2006-01-02T15:04:05.000Z"), - Usage: usage, - SDK: t.sdk, + ID: usageTelemetryEventID(), + Object: "event", + SpecVersion: "1.0", + DataContentType: "application/json; charset=utf-8", + Type: usageTelemetryEventType, + Source: "urn:redocly:cli", + Time: windowStart.UTC().Format("2006-01-02T15:04:05.000Z"), + Origin: "redocly-sdk", + Category: "product", + Signal: "metric", + Data: []map[string]any{operationItem, t.sdk}, }) } return events } +// usageTelemetryEventID returns evt_ and a lowercase ULID: 10 characters of the time +// in milliseconds, then 16 random characters. +func usageTelemetryEventID() string { + id := make([]byte, 26) + millis := time.Now().UnixMilli() + for index := 9; index >= 0; index-- { + id[index] = usageTelemetryCrockfordBase32[millis%32] + millis /= 32 + } + random := make([]byte, 16) + rand.Read(random) + for index, value := range random { + id[10+index] = usageTelemetryCrockfordBase32[value%32] + } + return "evt_" + string(id) +} + type usageTelemetryRejectedError struct { status int } @@ -342,24 +378,30 @@ func usageTelemetryOtlpRequest(events []UsageTelemetryEvent) map[string]any { for _, event := range events { eventTime, _ := time.Parse(time.RFC3339, event.Time) timeUnixNano := strconv.FormatInt(eventTime.UnixMilli(), 10) + "000000" + // The attributes that processCloudEventAttributes of @redocly/cli-otel gives for the event. attributes := []map[string]any{ usageTelemetryAttribute("cloudevents.event_id", event.ID), usageTelemetryAttribute("cloudevents.event_type", event.Type), usageTelemetryAttribute("cloudevents.event_source", event.Source), - usageTelemetryAttribute("cloudevents.event_spec_version", "1.0"), - usageTelemetryAttribute("cloudevents.event_data_content_type", "application/json; charset=utf-8"), + usageTelemetryAttribute("cloudevents.event_spec_version", event.SpecVersion), + usageTelemetryAttribute("cloudevents.event_data_content_type", event.DataContentType), usageTelemetryAttribute("cloudevents.event_time", event.Time), - usageTelemetryAttribute("cloudevents.event_subject", ""), - usageTelemetryAttribute("cloudevents.event_origin", usageTelemetryServiceName), - usageTelemetryAttribute("cloudevents.event_object", "event"), - usageTelemetryAttribute("cloudevents.event_category", "product"), - usageTelemetryAttribute("cloudevents.event_signal", "metric"), + usageTelemetryAttribute("cloudevents.event_subject", event.Data[0]["id"]), + usageTelemetryAttribute("cloudevents.event_origin", event.Origin), + usageTelemetryAttribute("cloudevents.event_object", event.Object), + usageTelemetryAttribute("cloudevents.event_category", event.Category), + usageTelemetryAttribute("cloudevents.event_signal", event.Signal), + usageTelemetryAttribute("cloudevents.event_request_id", ""), } - for key, value := range event.Usage { - attributes = append(attributes, usageTelemetryAttribute("cloudevents.event_data.usage."+key, value)) + for _, item := range event.Data { + for key, value := range item { + attributes = append(attributes, usageTelemetryAttribute(fmt.Sprintf("cloudevents.event_data.%s.%s", item["object"], key), value)) + } } - for key, value := range event.SDK { - attributes = append(attributes, usageTelemetryAttribute("cloudevents.event_data.sdk."+key, value)) + for _, item := range event.Data { + for _, key := range []string{"id", "object", "uri"} { + attributes = append(attributes, usageTelemetryAttribute(fmt.Sprintf("cloudevents.event_subjects.%s.%s", item["object"], key), item[key])) + } } spans = append(spans, map[string]any{ "traceId": usageTelemetryRandomHex(16), diff --git a/packages/client-generator/src/generators/php/telemetry/telemetry.php b/packages/client-generator/src/generators/php/telemetry/telemetry.php index 2f08e879f6..0a8f986b9a 100644 --- a/packages/client-generator/src/generators/php/telemetry/telemetry.php +++ b/packages/client-generator/src/generators/php/telemetry/telemetry.php @@ -13,7 +13,9 @@ * Register with `new Config(onSettled: [new UsageTelemetry(...)])`. PHP has no timers: * a call that finds its window elapsed sends that window first, in the calling request, * and a shutdown function sends the open windows. `sink`, when set, receives the events - * instead of a send; an exception it throws keeps the counts. + * instead of a send; an exception it throws keeps the counts. Each event is a + * `com.redocly.sdkUsage.aggregated` CloudEvent whose `data` holds the operation, then the SDK. + * The OpenTelemetry collector sets its `env`. */ final class UsageTelemetry { @@ -22,6 +24,8 @@ final class UsageTelemetry private const EVENT_TYPE = 'com.redocly.sdkUsage.aggregated'; private const SEND_TIMEOUT_MS = 3000; private const SERVICE_NAME = 'redocly-sdk'; + private const SDK_URN_PREFIX = 'urn:redocly:cli:sdk:php:'; + private const CROCKFORD_BASE32 = '0123456789abcdefghjkmnpqrstvwxyz'; private const STATUS_CLASSES = ['2xx', '3xx', '4xx', '5xx', 'network_error', 'timeout', 'aborted']; private const LATENCY_BUCKETS = [ 'lt_100ms' => 100, @@ -56,7 +60,9 @@ public function __construct( ) { $this->enabled = !self::disabled(); $this->sdk = [ - 'name' => $sdkName, + 'id' => $sdkName, + 'object' => 'sdk', + 'uri' => self::SDK_URN_PREFIX . $sdkName, 'version' => $sdkVersion, 'generator_version' => self::GENERATOR_VERSION, 'language' => 'php', @@ -221,8 +227,10 @@ private function toEvents(array $counts, float $windowStart, float $windowSecond $time = gmdate('Y-m-d\TH:i:s', (int) $windowStart) . sprintf('.%03dZ', $milliseconds); $events = []; foreach ($counts as $operationId => $operation) { - $usage = [ - 'operation_id' => (string) $operationId, + $operationItem = [ + 'id' => (string) $operationId, + 'object' => 'operation', + 'uri' => $this->sdk['uri'] . ':operation:' . $operationId, 'window_ms' => (int) ($windowSeconds * 1000), 'calls' => $operation['calls'], 'attempts' => $operation['attempts'], @@ -230,49 +238,76 @@ private function toEvents(array $counts, float $windowStart, float $windowSecond ]; foreach (self::STATUS_CLASSES as $status) { if (isset($operation['status'][$status])) { - $usage["status_{$status}"] = $operation['status'][$status]; + $operationItem["status_{$status}"] = $operation['status'][$status]; } } foreach (array_keys(self::LATENCY_BUCKETS) as $bucket) { if (isset($operation['latency'][$bucket])) { - $usage["latency_{$bucket}"] = $operation['latency'][$bucket]; + $operationItem["latency_{$bucket}"] = $operation['latency'][$bucket]; } } $events[] = [ - 'id' => 'evt_' . bin2hex(random_bytes(16)), + 'id' => self::eventId(), + 'object' => 'event', + 'specversion' => '1.0', + 'datacontenttype' => 'application/json; charset=utf-8', 'type' => self::EVENT_TYPE, - 'source' => $this->sdk['name'], + 'source' => 'urn:redocly:cli', 'time' => $time, - 'data' => ['usage' => $usage, 'sdk' => $this->sdk], + 'origin' => 'redocly-sdk', + 'category' => 'product', + 'signal' => 'metric', + 'data' => [$operationItem, $this->sdk], ]; } return $events; } + /** `evt_` and a lowercase ULID: 10 characters of the time in milliseconds, then 16 random ones. */ + private static function eventId(): string + { + $millis = (int) floor(microtime(true) * 1000); + $id = ''; + for ($index = 0; $index < 10; $index++) { + $id = self::CROCKFORD_BASE32[$millis % 32] . $id; + $millis = intdiv($millis, 32); + } + foreach (str_split(random_bytes(16)) as $byte) { + $id .= self::CROCKFORD_BASE32[ord($byte) % 32]; + } + return 'evt_' . $id; + } + private static function toOtlpRequest(array $events): array { $spans = []; foreach ($events as $event) { $started = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.v\Z', $event['time'], new \DateTimeZone('UTC')); $timeUnixNano = $started->format('Uv') . '000000'; + // The attributes that processCloudEventAttributes of @redocly/cli-otel gives for the event. $attributes = [ self::attribute('cloudevents.event_id', $event['id']), self::attribute('cloudevents.event_type', $event['type']), self::attribute('cloudevents.event_source', $event['source']), - self::attribute('cloudevents.event_spec_version', '1.0'), - self::attribute('cloudevents.event_data_content_type', 'application/json; charset=utf-8'), + self::attribute('cloudevents.event_spec_version', $event['specversion']), + self::attribute('cloudevents.event_data_content_type', $event['datacontenttype']), self::attribute('cloudevents.event_time', $event['time']), - self::attribute('cloudevents.event_subject', ''), - self::attribute('cloudevents.event_origin', self::SERVICE_NAME), - self::attribute('cloudevents.event_object', 'event'), - self::attribute('cloudevents.event_category', 'product'), - self::attribute('cloudevents.event_signal', 'metric'), + self::attribute('cloudevents.event_subject', $event['data'][0]['id']), + self::attribute('cloudevents.event_origin', $event['origin']), + self::attribute('cloudevents.event_object', $event['object']), + self::attribute('cloudevents.event_category', $event['category']), + self::attribute('cloudevents.event_signal', $event['signal']), + self::attribute('cloudevents.event_request_id', ''), ]; - foreach ($event['data']['usage'] as $key => $value) { - $attributes[] = self::attribute("cloudevents.event_data.usage.{$key}", $value); + foreach ($event['data'] as $item) { + foreach ($item as $key => $value) { + $attributes[] = self::attribute("cloudevents.event_data.{$item['object']}.{$key}", $value); + } } - foreach ($event['data']['sdk'] as $key => $value) { - $attributes[] = self::attribute("cloudevents.event_data.sdk.{$key}", $value); + foreach ($event['data'] as $item) { + foreach (['id', 'object', 'uri'] as $key) { + $attributes[] = self::attribute("cloudevents.event_subjects.{$item['object']}.{$key}", $item[$key]); + } } $spans[] = [ 'traceId' => bin2hex(random_bytes(16)), diff --git a/packages/client-generator/src/generators/python/telemetry/telemetry.py b/packages/client-generator/src/generators/python/telemetry/telemetry.py index f8ffe986f4..a1b9af36dc 100644 --- a/packages/client-generator/src/generators/python/telemetry/telemetry.py +++ b/packages/client-generator/src/generators/python/telemetry/telemetry.py @@ -23,6 +23,8 @@ _EVENT_TYPE = "com.redocly.sdkUsage.aggregated" _SEND_TIMEOUT_S = 3.0 _SERVICE_NAME = "redocly-sdk" +_SDK_URN_PREFIX = "urn:redocly:cli:sdk:python:" +_CROCKFORD_BASE32 = "0123456789abcdefghjkmnpqrstvwxyz" _STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "network_error", "timeout", "aborted"] _LATENCY_BUCKETS = [ ("lt_100ms", 100), @@ -33,6 +35,8 @@ ("gte_10s", math.inf), ] +# A com.redocly.sdkUsage.aggregated CloudEvent. Its "data" holds the operation, then the SDK. +# The OpenTelemetry collector sets the "env" of the event. SdkUsageEvent = Dict[str, Any] Sink = Callable[[List[SdkUsageEvent]], None] @@ -54,7 +58,7 @@ def _empty_counts() -> Dict[str, Any]: class UsageTelemetry: """The middleware that `telemetry()` returns. Safe to share across threads and clients.""" - def __init__(self, sdk: Dict[str, str], sink: Sink, flush_interval_ms: int, enabled: bool) -> None: + def __init__(self, sdk: Dict[str, Any], sink: Sink, flush_interval_ms: int, enabled: bool) -> None: self._enabled = enabled self._sdk = sdk self._sink = sink @@ -150,8 +154,10 @@ def telemetry( ) -> UsageTelemetry: """The usage telemetry middleware. REDOCLY_SDK_TELEMETRY=off or DO_NOT_TRACK=1 makes it record nothing.""" target = endpoint or _DEFAULT_ENDPOINT - sdk = { - "name": sdk_name, + sdk: Dict[str, Any] = { + "id": sdk_name, + "object": "sdk", + "uri": f"{_SDK_URN_PREFIX}{sdk_name}", "version": sdk_version, "generator_version": GENERATOR_VERSION, "language": "python", @@ -205,13 +211,15 @@ def _keep_after(error: Exception) -> bool: def _to_events( - counts: Dict[str, Dict[str, Any]], window_start: float, window_s: float, sdk: Dict[str, str] + counts: Dict[str, Dict[str, Any]], window_start: float, window_s: float, sdk: Dict[str, Any] ) -> List[SdkUsageEvent]: started = datetime.fromtimestamp(window_start, tz=timezone.utc) events: List[SdkUsageEvent] = [] for operation_id, operation in counts.items(): - usage: Dict[str, Any] = { - "operation_id": operation_id, + operation_item: Dict[str, Any] = { + "id": operation_id, + "object": "operation", + "uri": f"{sdk['uri']}:operation:{operation_id}", "window_ms": int(window_s * 1000), "calls": operation["calls"], "attempts": operation["attempts"], @@ -219,22 +227,41 @@ def _to_events( } for status in _STATUS_CLASSES: if status in operation["status"]: - usage[f"status_{status}"] = operation["status"][status] + operation_item[f"status_{status}"] = operation["status"][status] for bucket, _ in _LATENCY_BUCKETS: if bucket in operation["latency"]: - usage[f"latency_{bucket}"] = operation["latency"][bucket] + operation_item[f"latency_{bucket}"] = operation["latency"][bucket] events.append( { - "id": f"evt_{secrets.token_hex(16)}", + "id": _event_id(), + "object": "event", + "specversion": "1.0", + "datacontenttype": "application/json; charset=utf-8", "type": _EVENT_TYPE, - "source": sdk["name"], + "source": "urn:redocly:cli", "time": started.isoformat(timespec="milliseconds").replace("+00:00", "Z"), - "data": {"usage": usage, "sdk": sdk}, + "origin": "redocly-sdk", + "category": "product", + "signal": "metric", + "data": [operation_item, sdk], } ) return events +def _event_id() -> str: + """evt_ and a lowercase ULID: 10 characters of the time in milliseconds, then 16 random ones.""" + millis = time.time_ns() // 1_000_000 + time_part = "" + for _ in range(10): + time_part = _CROCKFORD_BASE32[millis % 32] + time_part + millis //= 32 + random_part = "" + for byte in secrets.token_bytes(16): + random_part += _CROCKFORD_BASE32[byte % 32] + return f"evt_{time_part}{random_part}" + + def _send_otlp(endpoint: str, events: List[SdkUsageEvent]) -> None: response = httpx.post( endpoint, @@ -252,23 +279,27 @@ def _to_otlp_request(events: List[SdkUsageEvent]) -> Dict[str, Any]: for event in events: started = datetime.fromisoformat(event["time"].replace("Z", "+00:00")) time_unix_nano = f"{int(started.timestamp() * 1000)}000000" + # The attributes that processCloudEventAttributes of @redocly/cli-otel gives for the event. attributes = [ _attribute("cloudevents.event_id", event["id"]), _attribute("cloudevents.event_type", event["type"]), _attribute("cloudevents.event_source", event["source"]), - _attribute("cloudevents.event_spec_version", "1.0"), - _attribute("cloudevents.event_data_content_type", "application/json; charset=utf-8"), + _attribute("cloudevents.event_spec_version", event["specversion"]), + _attribute("cloudevents.event_data_content_type", event["datacontenttype"]), _attribute("cloudevents.event_time", event["time"]), - _attribute("cloudevents.event_subject", ""), - _attribute("cloudevents.event_origin", _SERVICE_NAME), - _attribute("cloudevents.event_object", "event"), - _attribute("cloudevents.event_category", "product"), - _attribute("cloudevents.event_signal", "metric"), + _attribute("cloudevents.event_subject", event["data"][0]["id"]), + _attribute("cloudevents.event_origin", event["origin"]), + _attribute("cloudevents.event_object", event["object"]), + _attribute("cloudevents.event_category", event["category"]), + _attribute("cloudevents.event_signal", event["signal"]), + _attribute("cloudevents.event_request_id", ""), ] - for key, value in event["data"]["usage"].items(): - attributes.append(_attribute(f"cloudevents.event_data.usage.{key}", value)) - for key, value in event["data"]["sdk"].items(): - attributes.append(_attribute(f"cloudevents.event_data.sdk.{key}", value)) + for item in event["data"]: + for key, value in item.items(): + attributes.append(_attribute(f"cloudevents.event_data.{item['object']}.{key}", value)) + for item in event["data"]: + for key in ("id", "object", "uri"): + attributes.append(_attribute(f"cloudevents.event_subjects.{item['object']}.{key}", item[key])) spans.append( { "traceId": secrets.token_hex(16), diff --git a/packages/client-generator/src/generators/typescript/AGENTS.md b/packages/client-generator/src/generators/typescript/AGENTS.md index a5adadfe62..3a3590ad36 100644 --- a/packages/client-generator/src/generators/typescript/AGENTS.md +++ b/packages/client-generator/src/generators/typescript/AGENTS.md @@ -103,7 +103,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 diff --git a/packages/client-generator/src/generators/typescript/telemetry/__tests__/telemetry.test.ts b/packages/client-generator/src/generators/typescript/telemetry/__tests__/telemetry.test.ts index 96fd0e6ca0..e001d515cc 100644 --- a/packages/client-generator/src/generators/typescript/telemetry/__tests__/telemetry.test.ts +++ b/packages/client-generator/src/generators/typescript/telemetry/__tests__/telemetry.test.ts @@ -1,34 +1,8 @@ +import { CloudEvents, processCloudEventAttributes } from '@redocly/cli-otel'; + import type { Middleware, RequestContext, SettledCall } from '../../../../runtime-contract.js'; import type { SdkUsageEvent } from '../telemetry.js'; -const CLI_ATTRIBUTE_NAMES = [ - 'cloudevents.event_id', - 'cloudevents.event_type', - 'cloudevents.event_source', - 'cloudevents.event_spec_version', - 'cloudevents.event_data_content_type', - 'cloudevents.event_time', - 'cloudevents.event_subject', - 'cloudevents.event_origin', - 'cloudevents.event_object', - 'cloudevents.event_category', - 'cloudevents.event_signal', - 'cloudevents.event_data.usage.operation_id', - 'cloudevents.event_data.usage.window_ms', - 'cloudevents.event_data.usage.calls', - 'cloudevents.event_data.usage.attempts', - 'cloudevents.event_data.usage.duration_sum_ms', - 'cloudevents.event_data.usage.status_2xx', - 'cloudevents.event_data.usage.latency_lt_100ms', - 'cloudevents.event_data.sdk.name', - 'cloudevents.event_data.sdk.version', - 'cloudevents.event_data.sdk.generator_version', - 'cloudevents.event_data.sdk.language', - 'cloudevents.event_data.sdk.runtime', - 'cloudevents.event_data.sdk.runtime_version', - 'cloudevents.event_data.sdk.client', -]; - type OtlpSpan = { traceId: string; spanId: string; @@ -98,7 +72,7 @@ describe('telemetry', () => { const abort = new DOMException('The operation was aborted.', 'AbortError'); await settle(middleware, 'getOrder', { error: abort, attempts: 0, durationMs: 0 }); await flush(); - expect(windows[0][0].data.usage).toMatchObject({ + expect(windows[0][0].data[0]).toMatchObject({ calls: 3, attempts: 4, status_network_error: 1, @@ -121,7 +95,7 @@ describe('telemetry', () => { const abort = new DOMException('The operation was aborted.', 'AbortError'); await settle(middleware, 'getOrder', { error: abort, attempts: 0, durationMs: 0 }); await flush(); - expect(windows[0][0].data.usage).toMatchObject({ + expect(windows[0][0].data[0]).toMatchObject({ calls: 2, attempts: 2, status_2xx: 1, @@ -140,17 +114,15 @@ describe('telemetry', () => { await vi.advanceTimersByTimeAsync(60_000); expect(windows).toHaveLength(1); expect(windows[0][0].time).toBe('2026-10-20T09:14:00.000Z'); - expect(windows[0][0].data.usage.window_ms).toBe(60_000); - expect( - windows[0].map((event) => [event.data.usage.operation_id, event.data.usage.calls]) - ).toEqual([ + expect(windows[0][0].data[0].window_ms).toBe(60_000); + expect(windows[0].map((event) => [event.data[0].id, event.data[0].calls])).toEqual([ ['getOrder', 2], ['listOrders', 1], ]); await settle(middleware, 'getOrder', ok); await vi.advanceTimersByTimeAsync(60_000); - expect(windows[1].map((event) => event.data.usage.calls)).toEqual([1]); + expect(windows[1].map((event) => event.data[0].calls)).toEqual([1]); }); it('sends nothing for an empty window and starts no timer while idle', async () => { @@ -259,7 +231,7 @@ describe('telemetry failed send', () => { return bodies; } - const USAGE_PREFIX = 'cloudevents.event_data.usage.'; + const OPERATION_PREFIX = 'cloudevents.event_data.operation.'; function sentUsage(body: OtlpRequest): Array> { const usages: Array> = []; @@ -268,8 +240,8 @@ describe('telemetry failed send', () => { for (const attribute of span.attributes) { if (attribute.key === 'cloudevents.event_time') { usage.time = attribute.value.stringValue; - } else if (attribute.key.startsWith(USAGE_PREFIX)) { - const name = attribute.key.slice(USAGE_PREFIX.length); + } else if (attribute.key.startsWith(OPERATION_PREFIX)) { + const name = attribute.key.slice(OPERATION_PREFIX.length); usage[name] = attribute.value.intValue ?? attribute.value.stringValue; } } @@ -303,7 +275,9 @@ describe('telemetry failed send', () => { expect(sentUsage(bodies[1])).toEqual([ { time: '2026-10-20T09:14:00.000Z', - operation_id: 'getInvoice', + id: 'getInvoice', + object: 'operation', + uri: 'urn:redocly:cli:sdk:typescript:@acme/billing-sdk:operation:getInvoice', window_ms: 120_000, calls: 3, attempts: 4, @@ -449,8 +423,8 @@ describe('telemetry failed send', () => { expect(body.resourceSpans[0].scopeSpans[0].spans).toHaveLength(2); } expect(sentUsage(bodies[10])).toMatchObject([ - { operation_id: 'getOrder', calls: 10, window_ms: 660_000 }, - { operation_id: 'listOrders', calls: 10, window_ms: 660_000 }, + { id: 'getOrder', calls: 10, window_ms: 660_000 }, + { id: 'listOrders', calls: 10, window_ms: 660_000 }, ]); }); }); @@ -474,7 +448,7 @@ describe('telemetry default sink', () => { return { bodies, urls }; } - it('posts OTLP/JSON spans with the collector attribute names under service.name redocly-sdk', async () => { + it('posts OTLP/JSON spans with the attributes that @redocly/cli-otel gives for the event', async () => { const { bodies, urls } = fetchSpy(); const { telemetry, flush } = await loadTelemetry(); const middleware = telemetry(identity); @@ -495,15 +469,49 @@ describe('telemetry default sink', () => { expect(spans).toHaveLength(1); expect(spans[0].name).toBe('event.com.redocly.sdkUsage.aggregated'); expect(spans[0].startTimeUnixNano).toBe(spans[0].endTimeUnixNano); - expect(spans[0].attributes.map((attribute) => attribute.key)).toEqual(CLI_ATTRIBUTE_NAMES); - const values = new Map(); - for (const attribute of spans[0].attributes) values.set(attribute.key, attribute.value); - expect(values.get('cloudevents.event_data_content_type')).toEqual({ - stringValue: 'application/json; charset=utf-8', + const sent: Record = {}; + for (const attribute of spans[0].attributes) { + sent[attribute.key] = attribute.value.stringValue ?? attribute.value.intValue; + } + expect(sent['cloudevents.event_id']).toMatch(/^evt_[0-9abcdefghjkmnpqrstvwxyz]{26}$/); + const sdkUri = 'urn:redocly:cli:sdk:typescript:@acme/billing-sdk'; + const event = CloudEvents.mapToCloudEvent({ + type: 'com.redocly.sdkUsage.aggregated', + source: 'urn:redocly:cli', + origin: 'redocly-sdk', + category: 'product', + signal: 'metric', + data: [ + { + id: 'getOrder', + object: 'operation', + uri: `${sdkUri}:operation:getOrder`, + window_ms: sent['cloudevents.event_data.operation.window_ms'], + calls: 1, + attempts: 1, + duration_sum_ms: 10, + status_2xx: 1, + latency_lt_100ms: 1, + }, + { + id: '@acme/billing-sdk', + object: 'sdk', + uri: sdkUri, + version: '3.2.0', + generator_version: 'dev', + language: 'typescript', + runtime: 'node', + runtime_version: process.versions.node, + client: 'worker/1.8.4', + }, + ], }); - expect(values.get('cloudevents.event_origin')).toEqual({ stringValue: 'redocly-sdk' }); - expect(values.get('cloudevents.event_category')).toEqual({ stringValue: 'product' }); - expect(values.get('cloudevents.event_signal')).toEqual({ stringValue: 'metric' }); + const expected = processCloudEventAttributes( + { ...event, id: sent['cloudevents.event_id'] }, + new Date(String(sent['cloudevents.event_time'])) + ); + // `toEqual` skips the attributes that the mapper leaves undefined, such as `env`. + expect(sent).toEqual(expected); expect(JSON.stringify(spans[0])).not.toContain('api.example.com'); }); diff --git a/packages/client-generator/src/generators/typescript/telemetry/telemetry.ts b/packages/client-generator/src/generators/typescript/telemetry/telemetry.ts index ac889b8fd8..6769928675 100644 --- a/packages/client-generator/src/generators/typescript/telemetry/telemetry.ts +++ b/packages/client-generator/src/generators/typescript/telemetry/telemetry.ts @@ -1,3 +1,5 @@ +import type { SdkUsageAggregatedMessage } from '@redocly/cli-otel/lib/generated-async-redocly-cli/messages/SdkUsageAggregatedMessage.js'; + import type { Middleware, SettledCall } from '../../../runtime-contract.js'; const GENERATOR_VERSION: string = 'dev'; @@ -5,6 +7,7 @@ const DEFAULT_ENDPOINT = 'https://otel.cloud.redocly.com/v1/traces'; const EVENT_TYPE = 'com.redocly.sdkUsage.aggregated'; const SEND_TIMEOUT_MS = 3000; const SERVICE_NAME = 'redocly-sdk'; +const CROCKFORD_BASE32 = '0123456789abcdefghjkmnpqrstvwxyz'; type StatusClass = '2xx' | '3xx' | '4xx' | '5xx' | 'network_error' | 'timeout' | 'aborted'; type LatencyBucket = 'lt_100ms' | 'lt_300ms' | 'lt_1s' | 'lt_3s' | 'lt_10s' | 'gte_10s'; @@ -27,16 +30,17 @@ const LATENCY_BUCKETS: Array<{ bucket: LatencyBucket; belowMs: number }> = [ { bucket: 'gte_10s', belowMs: Infinity }, ]; -export type SdkUsageEvent = { - id: string; - type: typeof EVENT_TYPE; - source: string; - time: string; - data: { - usage: Record; - sdk: Record; - }; -}; +// The package root does not export the message types, so they come from their own file. +// The prepare script inlines them. +export type { + BaseEventMessage, + SdkUsageAggregatedMessage, +} from '@redocly/cli-otel/lib/generated-async-redocly-cli/messages/SdkUsageAggregatedMessage.js'; + +/** The `com.redocly.sdkUsage.aggregated` CloudEvent. The OpenTelemetry collector sets `env`. */ +export type SdkUsageEvent = Omit; +type OperationItem = SdkUsageEvent['data'][0]; +type SdkItem = SdkUsageEvent['data'][1]; export type TelemetryOptions = { sdkName: string; @@ -106,8 +110,10 @@ export function telemetry(options: TelemetryOptions): Middleware { runtime = 'deno'; runtimeVersion = versions.deno; } - const sdk: Record = { - name: options.sdkName, + const sdk: SdkItem = { + id: options.sdkName, + object: 'sdk', + uri: `urn:redocly:cli:sdk:typescript:${options.sdkName}`, version: options.sdkVersion, generator_version: GENERATOR_VERSION, language: 'typescript', @@ -131,7 +137,7 @@ export function telemetry(options: TelemetryOptions): Middleware { if (timer !== undefined) clearTimeout(timer); timer = undefined; activeWindows.delete(flushWindow); - const events = toEvents(counts, windowStart, Date.now() - windowStart, options.sdkName, sdk); + const events = toEvents(counts, windowStart, Date.now() - windowStart, sdk); const sentCounts = counts; const sentWindowStart = windowStart; counts = new Map(); @@ -242,13 +248,14 @@ function toEvents( counts: Map, windowStart: number, windowMs: number, - source: string, - sdk: Record + sdk: SdkItem ): SdkUsageEvent[] { const events: SdkUsageEvent[] = []; for (const [operationId, operation] of counts) { - const usage: Record = { - operation_id: operationId, + const operationItem: OperationItem = { + id: operationId, + object: 'operation', + uri: `${sdk.uri}:operation:${operationId}`, window_ms: windowMs, calls: operation.calls, attempts: operation.attempts, @@ -257,26 +264,46 @@ function toEvents( for (const statusClass of STATUS_CLASSES) { const count = operation.status[statusClass]; if (count > 0) { - usage[`status_${statusClass}`] = count; + operationItem[`status_${statusClass}`] = count; } } for (const { bucket } of LATENCY_BUCKETS) { const count = operation.latency[bucket]; if (count > 0) { - usage[`latency_${bucket}`] = count; + operationItem[`latency_${bucket}`] = count; } } events.push({ - id: `evt_${randomHex(16)}`, + id: eventId(), + object: 'event', + specversion: '1.0', + datacontenttype: 'application/json; charset=utf-8', type: EVENT_TYPE, - source, + source: 'urn:redocly:cli', time: new Date(windowStart).toISOString(), - data: { usage, sdk }, + origin: 'redocly-sdk', + category: 'product', + signal: 'metric', + data: [operationItem, sdk], }); } return events; } +/** `evt_` and a lowercase ULID: 10 characters of the time in milliseconds, then 16 random ones. */ +function eventId(): string { + let time = Date.now(); + let id = ''; + for (let index = 0; index < 10; index++) { + id = CROCKFORD_BASE32[time % 32] + id; + time = Math.floor(time / 32); + } + for (const byte of crypto.getRandomValues(new Uint8Array(16))) { + id += CROCKFORD_BASE32[byte % 32]; + } + return `evt_${id}`; +} + async function sendOtlp(endpoint: string, events: SdkUsageEvent[]): Promise { const response = await fetch(endpoint, { method: 'POST', @@ -318,24 +345,32 @@ function toOtlpRequest(events: SdkUsageEvent[]) { const spans = []; for (const event of events) { const timeUnixNano = `${Date.parse(event.time)}000000`; + // The attributes that `processCloudEventAttributes` of @redocly/cli-otel gives for the event. const attributes: OtlpAttribute[] = [ attribute('cloudevents.event_id', event.id), attribute('cloudevents.event_type', event.type), attribute('cloudevents.event_source', event.source), - attribute('cloudevents.event_spec_version', '1.0'), - attribute('cloudevents.event_data_content_type', 'application/json; charset=utf-8'), + attribute('cloudevents.event_spec_version', event.specversion), + attribute('cloudevents.event_data_content_type', event.datacontenttype), attribute('cloudevents.event_time', event.time), - attribute('cloudevents.event_subject', ''), - attribute('cloudevents.event_origin', SERVICE_NAME), - attribute('cloudevents.event_object', 'event'), - attribute('cloudevents.event_category', 'product'), - attribute('cloudevents.event_signal', 'metric'), + attribute('cloudevents.event_subject', event.data[0].id), + attribute('cloudevents.event_origin', event.origin), + attribute('cloudevents.event_object', event.object), + attribute('cloudevents.event_category', event.category), + attribute('cloudevents.event_signal', event.signal), + attribute('cloudevents.event_request_id', ''), ]; - for (const [key, value] of Object.entries(event.data.usage)) { - attributes.push(attribute(`cloudevents.event_data.usage.${key}`, value)); + for (const item of event.data) { + for (const [key, value] of Object.entries(item)) { + if (value !== undefined) { + attributes.push(attribute(`cloudevents.event_data.${item.object}.${key}`, value)); + } + } } - for (const [key, value] of Object.entries(event.data.sdk)) { - attributes.push(attribute(`cloudevents.event_data.sdk.${key}`, value)); + for (const item of event.data) { + attributes.push(attribute(`cloudevents.event_subjects.${item.object}.id`, item.id)); + attributes.push(attribute(`cloudevents.event_subjects.${item.object}.object`, item.object)); + attributes.push(attribute(`cloudevents.event_subjects.${item.object}.uri`, item.uri)); } spans.push({ traceId: randomHex(16), diff --git a/packages/client-generator/src/runtime-sources/__tests__/typescript.test.ts b/packages/client-generator/src/runtime-sources/__tests__/typescript.test.ts index 552051feee..714e2df5ed 100644 --- a/packages/client-generator/src/runtime-sources/__tests__/typescript.test.ts +++ b/packages/client-generator/src/runtime-sources/__tests__/typescript.test.ts @@ -60,11 +60,26 @@ describe('runtime-sources', () => { ); }); - it('the embedded telemetry source matches telemetry.ts', () => { + it('the embedded telemetry source inlines the event type from @redocly/cli-otel', () => { const source = readFileSync( join(pkgSrc, 'generators', 'typescript', 'telemetry', 'telemetry.ts'), 'utf-8' ); - expect(TELEMETRY_RUNTIME_SOURCE, STALE).toBe(source); + expect(TELEMETRY_RUNTIME_SOURCE).not.toContain("from '@redocly/cli-otel/"); + expect(TELEMETRY_RUNTIME_SOURCE, STALE).toContain( + 'export type SdkUsageAggregatedMessage = BaseEventMessage & {' + ); + // The module's own code is the source file's, byte-for-byte, around the re-export that the + // declarations replace. + expect(TELEMETRY_RUNTIME_SOURCE, STALE).toContain( + between(source, 'const GENERATOR_VERSION', '\n\n// The package root does not export') + ); + expect(TELEMETRY_RUNTIME_SOURCE, STALE).toContain( + between( + source, + '/** The `com.redocly.sdkUsage.aggregated` CloudEvent.', + '\n\nfunction randomHex' + ) + ); }); }); diff --git a/packages/client-generator/src/runtime-sources/go-telemetry.ts b/packages/client-generator/src/runtime-sources/go-telemetry.ts index a97cd623cd..26941fadc3 100644 --- a/packages/client-generator/src/runtime-sources/go-telemetry.ts +++ b/packages/client-generator/src/runtime-sources/go-telemetry.ts @@ -1,6 +1,6 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const GO_TELEMETRY_SOURCE = - '// Usage telemetry for a generated Go SDK, written beside the client by\n// `client.telemetry: on`. It counts calls per operation through the runtime\'s\n// OnSettled hook and sends one event per operation and window. An event holds\n// only the operation id, the counts, the window, and the SDK identity.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"crypto/rand"\n\t"encoding/hex"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"math"\n\t"net/http"\n\t"os"\n\t"runtime"\n\t"strconv"\n\t"strings"\n\t"sync"\n\t"time"\n)\n\nconst (\n\tusageTelemetryGeneratorVersion = "dev"\n\tusageTelemetryEndpoint = "https://otel.cloud.redocly.com/v1/traces"\n\tusageTelemetryEventType = "com.redocly.sdkUsage.aggregated"\n\tusageTelemetryServiceName = "redocly-sdk"\n\tusageTelemetrySendTimeout = 3 * time.Second\n)\n\n// usageTelemetryRegistered holds each UsageTelemetry that Register added.\nvar usageTelemetryRegistered []*UsageTelemetry\n\nvar usageTelemetryLatencyBuckets = []struct {\n\tname string\n\tbelowMs int64\n}{\n\t{"lt_100ms", 100},\n\t{"lt_300ms", 300},\n\t{"lt_1s", 1000},\n\t{"lt_3s", 3000},\n\t{"lt_10s", 10000},\n\t{"gte_10s", math.MaxInt64},\n}\n\n// UsageTelemetryOptions identifies the SDK. Endpoint defaults to the Redocly\n// OpenTelemetry endpoint and FlushInterval to one minute. Sink, when set,\n// receives the events instead of a send; an error it returns keeps the counts.\ntype UsageTelemetryOptions struct {\n\tSDKName string\n\tSDKVersion string\n\tClient string\n\tEndpoint string\n\tSink func(events []UsageTelemetryEvent) error\n\tFlushInterval time.Duration\n}\n\n// UsageTelemetryEvent is the counts of one operation in one window. Usage values\n// are strings or int64; Time is the window start in RFC 3339 with milliseconds.\ntype UsageTelemetryEvent struct {\n\tID string\n\tType string\n\tSource string\n\tTime string\n\tUsage map[string]any\n\tSDK map[string]string\n}\n\ntype usageTelemetryCounts struct {\n\tcalls int64\n\tattempts int64\n\tdurationSumMs int64\n\tstatus map[string]int64\n\tlatency map[string]int64\n}\n\n// UsageTelemetry counts the calls of every client whose Config.Middleware holds\n// its Middleware(). It is safe for concurrent use.\ntype UsageTelemetry struct {\n\toptions UsageTelemetryOptions\n\tsdk map[string]string\n\tmu sync.Mutex\n\tcounts map[string]*usageTelemetryCounts\n\twindowStart time.Time\n\ttimer *time.Timer\n\tsending sync.Mutex\n}\n\n// NewUsageTelemetry returns the counter. REDOCLY_SDK_TELEMETRY=off or\n// DO_NOT_TRACK=1 turns it off: Middleware then has no hooks.\nfunc NewUsageTelemetry(options UsageTelemetryOptions) *UsageTelemetry {\n\tif options.Endpoint == "" {\n\t\toptions.Endpoint = usageTelemetryEndpoint\n\t}\n\tif options.FlushInterval <= 0 {\n\t\toptions.FlushInterval = time.Minute\n\t}\n\treturn &UsageTelemetry{\n\t\toptions: options,\n\t\tsdk: map[string]string{\n\t\t\t"name": options.SDKName,\n\t\t\t"version": options.SDKVersion,\n\t\t\t"generator_version": usageTelemetryGeneratorVersion,\n\t\t\t"language": "go",\n\t\t\t"runtime": "go",\n\t\t\t"runtime_version": strings.TrimPrefix(runtime.Version(), "go"),\n\t\t\t"client": options.Client,\n\t\t},\n\t\tcounts: map[string]*usageTelemetryCounts{},\n\t}\n}\n\n// Middleware is the hook to add to Config.Middleware.\nfunc (t *UsageTelemetry) Middleware() Middleware {\n\tif usageTelemetryDisabled() {\n\t\treturn Middleware{}\n\t}\n\treturn Middleware{OnSettled: t.record}\n}\n\n// Register adds the middleware to every client of the package, also to a client created\n// before. Call it once, before the first call, for example in an init function.\nfunc (t *UsageTelemetry) Register() {\n\tregisteredMiddleware = append(registeredMiddleware, t.Middleware())\n\tusageTelemetryRegistered = append(usageTelemetryRegistered, t)\n}\n\n// FlushUsageTelemetry flushes every UsageTelemetry that Register added, as Flush does.\n// Go has no exit hook, so call it before the program ends.\nfunc FlushUsageTelemetry(ctx context.Context) error {\n\tfor _, telemetry := range usageTelemetryRegistered {\n\t\tif err := telemetry.Flush(ctx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Flush sends the open window and waits for the send, or for ctx.\n// Go has no exit hook, so call it before the program ends.\nfunc (t *UsageTelemetry) Flush(ctx context.Context) error {\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tt.flushWindow()\n\t\tclose(done)\n\t}()\n\tselect {\n\tcase <-done:\n\t\treturn nil\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\t}\n}\n\nfunc usageTelemetryDisabled() bool {\n\treturn os.Getenv("REDOCLY_SDK_TELEMETRY") == "off" || os.Getenv("DO_NOT_TRACK") == "1"\n}\n\nfunc (t *UsageTelemetry) record(call SettledCall) {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tif len(t.counts) == 0 {\n\t\tt.openWindow()\n\t}\n\toperation := t.counts[call.OperationID]\n\tif operation == nil {\n\t\toperation = &usageTelemetryCounts{status: map[string]int64{}, latency: map[string]int64{}}\n\t\tt.counts[call.OperationID] = operation\n\t}\n\tdurationMs := call.Duration.Milliseconds()\n\toperation.calls++\n\toperation.attempts += int64(call.Attempts)\n\toperation.durationSumMs += durationMs\n\toperation.status[usageTelemetryStatusClass(call)]++\n\tfor _, bucket := range usageTelemetryLatencyBuckets {\n\t\tif durationMs < bucket.belowMs {\n\t\t\toperation.latency[bucket.name]++\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// openWindow must run with t.mu held.\nfunc (t *UsageTelemetry) openWindow() {\n\tt.windowStart = time.Now()\n\tt.timer = time.AfterFunc(t.options.FlushInterval, t.flushWindow)\n}\n\nfunc (t *UsageTelemetry) flushWindow() {\n\t// One send at a time, so Flush also waits for a send that the timer started.\n\tt.sending.Lock()\n\tdefer t.sending.Unlock()\n\tt.mu.Lock()\n\tif len(t.counts) == 0 {\n\t\tt.mu.Unlock()\n\t\treturn\n\t}\n\tt.timer.Stop()\n\tsentCounts := t.counts\n\tsentStart := t.windowStart\n\tt.counts = map[string]*usageTelemetryCounts{}\n\tt.mu.Unlock()\n\n\tif usageTelemetryDisabled() {\n\t\treturn\n\t}\n\tevents := t.toEvents(sentCounts, sentStart, time.Since(sentStart))\n\terr := t.send(events)\n\tif err == nil || !usageTelemetryKeepAfter(err) {\n\t\treturn\n\t}\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tif len(t.counts) == 0 {\n\t\tt.openWindow()\n\t}\n\tif sentStart.Before(t.windowStart) {\n\t\tt.windowStart = sentStart\n\t}\n\tfor operationID, sent := range sentCounts {\n\t\toperation := t.counts[operationID]\n\t\tif operation == nil {\n\t\t\tt.counts[operationID] = sent\n\t\t\tcontinue\n\t\t}\n\t\toperation.calls += sent.calls\n\t\toperation.attempts += sent.attempts\n\t\toperation.durationSumMs += sent.durationSumMs\n\t\tfor key, count := range sent.status {\n\t\t\toperation.status[key] += count\n\t\t}\n\t\tfor key, count := range sent.latency {\n\t\t\toperation.latency[key] += count\n\t\t}\n\t}\n}\n\nfunc usageTelemetryStatusClass(call SettledCall) string {\n\tif call.Response == nil {\n\t\tvar timeout *TimeoutError\n\t\t// The caller\'s own deadline is a timeout too, as a caller\'s AbortSignal.timeout() is in TypeScript.\n\t\tif errors.As(call.Err, &timeout) || errors.Is(call.Err, context.DeadlineExceeded) {\n\t\t\treturn "timeout"\n\t\t}\n\t\tif errors.Is(call.Err, context.Canceled) {\n\t\t\treturn "aborted"\n\t\t}\n\t\treturn "network_error"\n\t}\n\tstatus := call.Response.StatusCode\n\tswitch {\n\tcase status >= 500:\n\t\treturn "5xx"\n\tcase status >= 400:\n\t\treturn "4xx"\n\tcase status >= 300:\n\t\treturn "3xx"\n\t}\n\treturn "2xx"\n}\n\nfunc (t *UsageTelemetry) toEvents(counts map[string]*usageTelemetryCounts, windowStart time.Time, window time.Duration) []UsageTelemetryEvent {\n\tevents := []UsageTelemetryEvent{}\n\tfor operationID, operation := range counts {\n\t\tusage := map[string]any{\n\t\t\t"operation_id": operationID,\n\t\t\t"window_ms": window.Milliseconds(),\n\t\t\t"calls": operation.calls,\n\t\t\t"attempts": operation.attempts,\n\t\t\t"duration_sum_ms": operation.durationSumMs,\n\t\t}\n\t\tfor key, count := range operation.status {\n\t\t\tusage["status_"+key] = count\n\t\t}\n\t\tfor key, count := range operation.latency {\n\t\t\tusage["latency_"+key] = count\n\t\t}\n\t\tevents = append(events, UsageTelemetryEvent{\n\t\t\tID: "evt_" + usageTelemetryRandomHex(16),\n\t\t\tType: usageTelemetryEventType,\n\t\t\tSource: t.options.SDKName,\n\t\t\tTime: windowStart.UTC().Format("2006-01-02T15:04:05.000Z"),\n\t\t\tUsage: usage,\n\t\t\tSDK: t.sdk,\n\t\t})\n\t}\n\treturn events\n}\n\ntype usageTelemetryRejectedError struct {\n\tstatus int\n}\n\nfunc (e *usageTelemetryRejectedError) Error() string {\n\treturn fmt.Sprintf("the telemetry endpoint answered %d", e.status)\n}\n\nfunc (t *UsageTelemetry) send(events []UsageTelemetryEvent) error {\n\tif t.options.Sink != nil {\n\t\treturn t.options.Sink(events)\n\t}\n\tbody, err := json.Marshal(usageTelemetryOtlpRequest(events))\n\tif err != nil {\n\t\treturn err\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), usageTelemetrySendTimeout)\n\tdefer cancel()\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.options.Endpoint, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set("Content-Type", "application/json")\n\t// One send per window, so the connection is never reused.\n\treq.Close = true\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresp.Body.Close()\n\tif resp.StatusCode < 200 || resp.StatusCode > 299 {\n\t\treturn &usageTelemetryRejectedError{status: resp.StatusCode}\n\t}\n\treturn nil\n}\n\nfunc usageTelemetryKeepAfter(err error) bool {\n\t// The endpoint can have stored the counts of a send that timed out.\n\tif errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\tvar rejected *usageTelemetryRejectedError\n\tif errors.As(err, &rejected) {\n\t\treturn rejected.status == 408 || rejected.status == 429 || rejected.status >= 500\n\t}\n\treturn true\n}\n\nfunc usageTelemetryOtlpRequest(events []UsageTelemetryEvent) map[string]any {\n\tspans := []map[string]any{}\n\tfor _, event := range events {\n\t\teventTime, _ := time.Parse(time.RFC3339, event.Time)\n\t\ttimeUnixNano := strconv.FormatInt(eventTime.UnixMilli(), 10) + "000000"\n\t\tattributes := []map[string]any{\n\t\t\tusageTelemetryAttribute("cloudevents.event_id", event.ID),\n\t\t\tusageTelemetryAttribute("cloudevents.event_type", event.Type),\n\t\t\tusageTelemetryAttribute("cloudevents.event_source", event.Source),\n\t\t\tusageTelemetryAttribute("cloudevents.event_spec_version", "1.0"),\n\t\t\tusageTelemetryAttribute("cloudevents.event_data_content_type", "application/json; charset=utf-8"),\n\t\t\tusageTelemetryAttribute("cloudevents.event_time", event.Time),\n\t\t\tusageTelemetryAttribute("cloudevents.event_subject", ""),\n\t\t\tusageTelemetryAttribute("cloudevents.event_origin", usageTelemetryServiceName),\n\t\t\tusageTelemetryAttribute("cloudevents.event_object", "event"),\n\t\t\tusageTelemetryAttribute("cloudevents.event_category", "product"),\n\t\t\tusageTelemetryAttribute("cloudevents.event_signal", "metric"),\n\t\t}\n\t\tfor key, value := range event.Usage {\n\t\t\tattributes = append(attributes, usageTelemetryAttribute("cloudevents.event_data.usage."+key, value))\n\t\t}\n\t\tfor key, value := range event.SDK {\n\t\t\tattributes = append(attributes, usageTelemetryAttribute("cloudevents.event_data.sdk."+key, value))\n\t\t}\n\t\tspans = append(spans, map[string]any{\n\t\t\t"traceId": usageTelemetryRandomHex(16),\n\t\t\t"spanId": usageTelemetryRandomHex(8),\n\t\t\t"name": "event." + event.Type,\n\t\t\t"kind": 1,\n\t\t\t"startTimeUnixNano": timeUnixNano,\n\t\t\t"endTimeUnixNano": timeUnixNano,\n\t\t\t"attributes": attributes,\n\t\t})\n\t}\n\treturn map[string]any{\n\t\t"resourceSpans": []map[string]any{\n\t\t\t{\n\t\t\t\t"resource": map[string]any{\n\t\t\t\t\t"attributes": []map[string]any{\n\t\t\t\t\t\tusageTelemetryAttribute("service.name", usageTelemetryServiceName),\n\t\t\t\t\t\tusageTelemetryAttribute("service.version", usageTelemetryGeneratorVersion),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t"scopeSpans": []map[string]any{\n\t\t\t\t\t{"scope": map[string]any{"name": "redocly-sdk-telemetry"}, "spans": spans},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc usageTelemetryAttribute(key string, value any) map[string]any {\n\tswitch typed := value.(type) {\n\tcase int64:\n\t\treturn map[string]any{"key": key, "value": map[string]any{"intValue": typed}}\n\tcase string:\n\t\treturn map[string]any{"key": key, "value": map[string]any{"stringValue": typed}}\n\t}\n\treturn map[string]any{"key": key, "value": map[string]any{"stringValue": fmt.Sprint(value)}}\n}\n\nfunc usageTelemetryRandomHex(byteCount int) string {\n\tbuffer := make([]byte, byteCount)\n\trand.Read(buffer)\n\treturn hex.EncodeToString(buffer)\n}\n'; + '// Usage telemetry for a generated Go SDK, written beside the client by\n// `client.telemetry: on`. It counts calls per operation through the runtime\'s\n// OnSettled hook and sends one event per operation and window. An event holds\n// only the operation id, the counts, the window, and the SDK identity.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"crypto/rand"\n\t"encoding/hex"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"math"\n\t"net/http"\n\t"os"\n\t"runtime"\n\t"strconv"\n\t"strings"\n\t"sync"\n\t"time"\n)\n\nconst (\n\tusageTelemetryGeneratorVersion = "dev"\n\tusageTelemetryEndpoint = "https://otel.cloud.redocly.com/v1/traces"\n\tusageTelemetryEventType = "com.redocly.sdkUsage.aggregated"\n\tusageTelemetryServiceName = "redocly-sdk"\n\tusageTelemetrySendTimeout = 3 * time.Second\n\tusageTelemetrySDKURNPrefix = "urn:redocly:cli:sdk:go:"\n\tusageTelemetryCrockfordBase32 = "0123456789abcdefghjkmnpqrstvwxyz"\n)\n\n// usageTelemetryRegistered holds each UsageTelemetry that Register added.\nvar usageTelemetryRegistered []*UsageTelemetry\n\nvar usageTelemetryLatencyBuckets = []struct {\n\tname string\n\tbelowMs int64\n}{\n\t{"lt_100ms", 100},\n\t{"lt_300ms", 300},\n\t{"lt_1s", 1000},\n\t{"lt_3s", 3000},\n\t{"lt_10s", 10000},\n\t{"gte_10s", math.MaxInt64},\n}\n\n// UsageTelemetryOptions identifies the SDK. Endpoint defaults to the Redocly\n// OpenTelemetry endpoint and FlushInterval to one minute. Sink, when set,\n// receives the events instead of a send; an error it returns keeps the counts.\ntype UsageTelemetryOptions struct {\n\tSDKName string\n\tSDKVersion string\n\tClient string\n\tEndpoint string\n\tSink func(events []UsageTelemetryEvent) error\n\tFlushInterval time.Duration\n}\n\n// UsageTelemetryEvent is one com.redocly.sdkUsage.aggregated CloudEvent: the counts\n// of one operation in one window. Data holds the operation, then the SDK; their values\n// are strings or int64. Time is the window start in RFC 3339 with milliseconds. The\n// OpenTelemetry collector sets the env of the event. The json tags make a Sink that\n// marshals an event write the CloudEvent field names, as in the other languages.\ntype UsageTelemetryEvent struct {\n\tID string `json:"id"`\n\tObject string `json:"object"`\n\tSpecVersion string `json:"specversion"`\n\tDataContentType string `json:"datacontenttype"`\n\tType string `json:"type"`\n\tSource string `json:"source"`\n\tTime string `json:"time"`\n\tOrigin string `json:"origin"`\n\tCategory string `json:"category"`\n\tSignal string `json:"signal"`\n\tData []map[string]any `json:"data"`\n}\n\ntype usageTelemetryCounts struct {\n\tcalls int64\n\tattempts int64\n\tdurationSumMs int64\n\tstatus map[string]int64\n\tlatency map[string]int64\n}\n\n// UsageTelemetry counts the calls of every client whose Config.Middleware holds\n// its Middleware(). It is safe for concurrent use.\ntype UsageTelemetry struct {\n\toptions UsageTelemetryOptions\n\tsdk map[string]any\n\tmu sync.Mutex\n\tcounts map[string]*usageTelemetryCounts\n\twindowStart time.Time\n\ttimer *time.Timer\n\tsending sync.Mutex\n}\n\n// NewUsageTelemetry returns the counter. REDOCLY_SDK_TELEMETRY=off or\n// DO_NOT_TRACK=1 turns it off: Middleware then has no hooks.\nfunc NewUsageTelemetry(options UsageTelemetryOptions) *UsageTelemetry {\n\tif options.Endpoint == "" {\n\t\toptions.Endpoint = usageTelemetryEndpoint\n\t}\n\tif options.FlushInterval <= 0 {\n\t\toptions.FlushInterval = time.Minute\n\t}\n\treturn &UsageTelemetry{\n\t\toptions: options,\n\t\tsdk: map[string]any{\n\t\t\t"id": options.SDKName,\n\t\t\t"object": "sdk",\n\t\t\t"uri": usageTelemetrySDKURNPrefix + options.SDKName,\n\t\t\t"version": options.SDKVersion,\n\t\t\t"generator_version": usageTelemetryGeneratorVersion,\n\t\t\t"language": "go",\n\t\t\t"runtime": "go",\n\t\t\t"runtime_version": strings.TrimPrefix(runtime.Version(), "go"),\n\t\t\t"client": options.Client,\n\t\t},\n\t\tcounts: map[string]*usageTelemetryCounts{},\n\t}\n}\n\n// Middleware is the hook to add to Config.Middleware.\nfunc (t *UsageTelemetry) Middleware() Middleware {\n\tif usageTelemetryDisabled() {\n\t\treturn Middleware{}\n\t}\n\treturn Middleware{OnSettled: t.record}\n}\n\n// Register adds the middleware to every client of the package, also to a client created\n// before. Call it once, before the first call, for example in an init function.\nfunc (t *UsageTelemetry) Register() {\n\tregisteredMiddleware = append(registeredMiddleware, t.Middleware())\n\tusageTelemetryRegistered = append(usageTelemetryRegistered, t)\n}\n\n// FlushUsageTelemetry flushes every UsageTelemetry that Register added, as Flush does.\n// Go has no exit hook, so call it before the program ends.\nfunc FlushUsageTelemetry(ctx context.Context) error {\n\tfor _, telemetry := range usageTelemetryRegistered {\n\t\tif err := telemetry.Flush(ctx); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// Flush sends the open window and waits for the send, or for ctx.\n// Go has no exit hook, so call it before the program ends.\nfunc (t *UsageTelemetry) Flush(ctx context.Context) error {\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tt.flushWindow()\n\t\tclose(done)\n\t}()\n\tselect {\n\tcase <-done:\n\t\treturn nil\n\tcase <-ctx.Done():\n\t\treturn ctx.Err()\n\t}\n}\n\nfunc usageTelemetryDisabled() bool {\n\treturn os.Getenv("REDOCLY_SDK_TELEMETRY") == "off" || os.Getenv("DO_NOT_TRACK") == "1"\n}\n\nfunc (t *UsageTelemetry) record(call SettledCall) {\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tif len(t.counts) == 0 {\n\t\tt.openWindow()\n\t}\n\toperation := t.counts[call.OperationID]\n\tif operation == nil {\n\t\toperation = &usageTelemetryCounts{status: map[string]int64{}, latency: map[string]int64{}}\n\t\tt.counts[call.OperationID] = operation\n\t}\n\tdurationMs := call.Duration.Milliseconds()\n\toperation.calls++\n\toperation.attempts += int64(call.Attempts)\n\toperation.durationSumMs += durationMs\n\toperation.status[usageTelemetryStatusClass(call)]++\n\tfor _, bucket := range usageTelemetryLatencyBuckets {\n\t\tif durationMs < bucket.belowMs {\n\t\t\toperation.latency[bucket.name]++\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// openWindow must run with t.mu held.\nfunc (t *UsageTelemetry) openWindow() {\n\tt.windowStart = time.Now()\n\tt.timer = time.AfterFunc(t.options.FlushInterval, t.flushWindow)\n}\n\nfunc (t *UsageTelemetry) flushWindow() {\n\t// One send at a time, so Flush also waits for a send that the timer started.\n\tt.sending.Lock()\n\tdefer t.sending.Unlock()\n\tt.mu.Lock()\n\tif len(t.counts) == 0 {\n\t\tt.mu.Unlock()\n\t\treturn\n\t}\n\tt.timer.Stop()\n\tsentCounts := t.counts\n\tsentStart := t.windowStart\n\tt.counts = map[string]*usageTelemetryCounts{}\n\tt.mu.Unlock()\n\n\tif usageTelemetryDisabled() {\n\t\treturn\n\t}\n\tevents := t.toEvents(sentCounts, sentStart, time.Since(sentStart))\n\terr := t.send(events)\n\tif err == nil || !usageTelemetryKeepAfter(err) {\n\t\treturn\n\t}\n\tt.mu.Lock()\n\tdefer t.mu.Unlock()\n\tif len(t.counts) == 0 {\n\t\tt.openWindow()\n\t}\n\tif sentStart.Before(t.windowStart) {\n\t\tt.windowStart = sentStart\n\t}\n\tfor operationID, sent := range sentCounts {\n\t\toperation := t.counts[operationID]\n\t\tif operation == nil {\n\t\t\tt.counts[operationID] = sent\n\t\t\tcontinue\n\t\t}\n\t\toperation.calls += sent.calls\n\t\toperation.attempts += sent.attempts\n\t\toperation.durationSumMs += sent.durationSumMs\n\t\tfor key, count := range sent.status {\n\t\t\toperation.status[key] += count\n\t\t}\n\t\tfor key, count := range sent.latency {\n\t\t\toperation.latency[key] += count\n\t\t}\n\t}\n}\n\nfunc usageTelemetryStatusClass(call SettledCall) string {\n\tif call.Response == nil {\n\t\tvar timeout *TimeoutError\n\t\t// The caller\'s own deadline is a timeout too, as a caller\'s AbortSignal.timeout() is in TypeScript.\n\t\tif errors.As(call.Err, &timeout) || errors.Is(call.Err, context.DeadlineExceeded) {\n\t\t\treturn "timeout"\n\t\t}\n\t\tif errors.Is(call.Err, context.Canceled) {\n\t\t\treturn "aborted"\n\t\t}\n\t\treturn "network_error"\n\t}\n\tstatus := call.Response.StatusCode\n\tswitch {\n\tcase status >= 500:\n\t\treturn "5xx"\n\tcase status >= 400:\n\t\treturn "4xx"\n\tcase status >= 300:\n\t\treturn "3xx"\n\t}\n\treturn "2xx"\n}\n\nfunc (t *UsageTelemetry) toEvents(counts map[string]*usageTelemetryCounts, windowStart time.Time, window time.Duration) []UsageTelemetryEvent {\n\tevents := []UsageTelemetryEvent{}\n\tfor operationID, operation := range counts {\n\t\toperationItem := map[string]any{\n\t\t\t"id": operationID,\n\t\t\t"object": "operation",\n\t\t\t"uri": usageTelemetrySDKURNPrefix + t.options.SDKName + ":operation:" + operationID,\n\t\t\t"window_ms": window.Milliseconds(),\n\t\t\t"calls": operation.calls,\n\t\t\t"attempts": operation.attempts,\n\t\t\t"duration_sum_ms": operation.durationSumMs,\n\t\t}\n\t\tfor key, count := range operation.status {\n\t\t\toperationItem["status_"+key] = count\n\t\t}\n\t\tfor key, count := range operation.latency {\n\t\t\toperationItem["latency_"+key] = count\n\t\t}\n\t\tevents = append(events, UsageTelemetryEvent{\n\t\t\tID: usageTelemetryEventID(),\n\t\t\tObject: "event",\n\t\t\tSpecVersion: "1.0",\n\t\t\tDataContentType: "application/json; charset=utf-8",\n\t\t\tType: usageTelemetryEventType,\n\t\t\tSource: "urn:redocly:cli",\n\t\t\tTime: windowStart.UTC().Format("2006-01-02T15:04:05.000Z"),\n\t\t\tOrigin: "redocly-sdk",\n\t\t\tCategory: "product",\n\t\t\tSignal: "metric",\n\t\t\tData: []map[string]any{operationItem, t.sdk},\n\t\t})\n\t}\n\treturn events\n}\n\n// usageTelemetryEventID returns evt_ and a lowercase ULID: 10 characters of the time\n// in milliseconds, then 16 random characters.\nfunc usageTelemetryEventID() string {\n\tid := make([]byte, 26)\n\tmillis := time.Now().UnixMilli()\n\tfor index := 9; index >= 0; index-- {\n\t\tid[index] = usageTelemetryCrockfordBase32[millis%32]\n\t\tmillis /= 32\n\t}\n\trandom := make([]byte, 16)\n\trand.Read(random)\n\tfor index, value := range random {\n\t\tid[10+index] = usageTelemetryCrockfordBase32[value%32]\n\t}\n\treturn "evt_" + string(id)\n}\n\ntype usageTelemetryRejectedError struct {\n\tstatus int\n}\n\nfunc (e *usageTelemetryRejectedError) Error() string {\n\treturn fmt.Sprintf("the telemetry endpoint answered %d", e.status)\n}\n\nfunc (t *UsageTelemetry) send(events []UsageTelemetryEvent) error {\n\tif t.options.Sink != nil {\n\t\treturn t.options.Sink(events)\n\t}\n\tbody, err := json.Marshal(usageTelemetryOtlpRequest(events))\n\tif err != nil {\n\t\treturn err\n\t}\n\tctx, cancel := context.WithTimeout(context.Background(), usageTelemetrySendTimeout)\n\tdefer cancel()\n\treq, err := http.NewRequestWithContext(ctx, http.MethodPost, t.options.Endpoint, bytes.NewReader(body))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set("Content-Type", "application/json")\n\t// One send per window, so the connection is never reused.\n\treq.Close = true\n\tresp, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tresp.Body.Close()\n\tif resp.StatusCode < 200 || resp.StatusCode > 299 {\n\t\treturn &usageTelemetryRejectedError{status: resp.StatusCode}\n\t}\n\treturn nil\n}\n\nfunc usageTelemetryKeepAfter(err error) bool {\n\t// The endpoint can have stored the counts of a send that timed out.\n\tif errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\tvar rejected *usageTelemetryRejectedError\n\tif errors.As(err, &rejected) {\n\t\treturn rejected.status == 408 || rejected.status == 429 || rejected.status >= 500\n\t}\n\treturn true\n}\n\nfunc usageTelemetryOtlpRequest(events []UsageTelemetryEvent) map[string]any {\n\tspans := []map[string]any{}\n\tfor _, event := range events {\n\t\teventTime, _ := time.Parse(time.RFC3339, event.Time)\n\t\ttimeUnixNano := strconv.FormatInt(eventTime.UnixMilli(), 10) + "000000"\n\t\t// The attributes that processCloudEventAttributes of @redocly/cli-otel gives for the event.\n\t\tattributes := []map[string]any{\n\t\t\tusageTelemetryAttribute("cloudevents.event_id", event.ID),\n\t\t\tusageTelemetryAttribute("cloudevents.event_type", event.Type),\n\t\t\tusageTelemetryAttribute("cloudevents.event_source", event.Source),\n\t\t\tusageTelemetryAttribute("cloudevents.event_spec_version", event.SpecVersion),\n\t\t\tusageTelemetryAttribute("cloudevents.event_data_content_type", event.DataContentType),\n\t\t\tusageTelemetryAttribute("cloudevents.event_time", event.Time),\n\t\t\tusageTelemetryAttribute("cloudevents.event_subject", event.Data[0]["id"]),\n\t\t\tusageTelemetryAttribute("cloudevents.event_origin", event.Origin),\n\t\t\tusageTelemetryAttribute("cloudevents.event_object", event.Object),\n\t\t\tusageTelemetryAttribute("cloudevents.event_category", event.Category),\n\t\t\tusageTelemetryAttribute("cloudevents.event_signal", event.Signal),\n\t\t\tusageTelemetryAttribute("cloudevents.event_request_id", ""),\n\t\t}\n\t\tfor _, item := range event.Data {\n\t\t\tfor key, value := range item {\n\t\t\t\tattributes = append(attributes, usageTelemetryAttribute(fmt.Sprintf("cloudevents.event_data.%s.%s", item["object"], key), value))\n\t\t\t}\n\t\t}\n\t\tfor _, item := range event.Data {\n\t\t\tfor _, key := range []string{"id", "object", "uri"} {\n\t\t\t\tattributes = append(attributes, usageTelemetryAttribute(fmt.Sprintf("cloudevents.event_subjects.%s.%s", item["object"], key), item[key]))\n\t\t\t}\n\t\t}\n\t\tspans = append(spans, map[string]any{\n\t\t\t"traceId": usageTelemetryRandomHex(16),\n\t\t\t"spanId": usageTelemetryRandomHex(8),\n\t\t\t"name": "event." + event.Type,\n\t\t\t"kind": 1,\n\t\t\t"startTimeUnixNano": timeUnixNano,\n\t\t\t"endTimeUnixNano": timeUnixNano,\n\t\t\t"attributes": attributes,\n\t\t})\n\t}\n\treturn map[string]any{\n\t\t"resourceSpans": []map[string]any{\n\t\t\t{\n\t\t\t\t"resource": map[string]any{\n\t\t\t\t\t"attributes": []map[string]any{\n\t\t\t\t\t\tusageTelemetryAttribute("service.name", usageTelemetryServiceName),\n\t\t\t\t\t\tusageTelemetryAttribute("service.version", usageTelemetryGeneratorVersion),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t"scopeSpans": []map[string]any{\n\t\t\t\t\t{"scope": map[string]any{"name": "redocly-sdk-telemetry"}, "spans": spans},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc usageTelemetryAttribute(key string, value any) map[string]any {\n\tswitch typed := value.(type) {\n\tcase int64:\n\t\treturn map[string]any{"key": key, "value": map[string]any{"intValue": typed}}\n\tcase string:\n\t\treturn map[string]any{"key": key, "value": map[string]any{"stringValue": typed}}\n\t}\n\treturn map[string]any{"key": key, "value": map[string]any{"stringValue": fmt.Sprint(value)}}\n}\n\nfunc usageTelemetryRandomHex(byteCount int) string {\n\tbuffer := make([]byte, byteCount)\n\trand.Read(buffer)\n\treturn hex.EncodeToString(buffer)\n}\n'; export const GO_TELEMETRY_DECLARED_NAMES = [ 'UsageTelemetryOptions', diff --git a/packages/client-generator/src/runtime-sources/php-telemetry.ts b/packages/client-generator/src/runtime-sources/php-telemetry.ts index e9d3665c2c..f27c18b488 100644 --- a/packages/client-generator/src/runtime-sources/php-telemetry.ts +++ b/packages/client-generator/src/runtime-sources/php-telemetry.ts @@ -1,5 +1,5 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const PHP_TELEMETRY_SOURCE = - " 100,\n 'lt_300ms' => 300,\n 'lt_1s' => 1000,\n 'lt_3s' => 3000,\n 'lt_10s' => 10000,\n 'gte_10s' => PHP_INT_MAX,\n ];\n\n private bool $enabled;\n private array $sdk;\n private string $endpoint;\n private ?\\Closure $sink;\n /** @var array The instances with an open window, by object id. */\n private static array $openWindows = [];\n private static bool $shutdownRegistered = false;\n /** @var array The instances that `register` added. */\n private static array $registered = [];\n\n private array $counts = [];\n private float $windowStart = 0.0;\n private float $nextSendAt = 0.0;\n\n public function __construct(\n string $sdkName,\n string $sdkVersion,\n string $client,\n ?callable $sink = null,\n ?string $endpoint = null,\n private int $flushIntervalMs = 60_000,\n ) {\n $this->enabled = !self::disabled();\n $this->sdk = [\n 'name' => $sdkName,\n 'version' => $sdkVersion,\n 'generator_version' => self::GENERATOR_VERSION,\n 'language' => 'php',\n 'runtime' => 'php',\n 'runtime_version' => PHP_VERSION,\n 'client' => $client,\n ];\n $this->endpoint = $endpoint ?? self::DEFAULT_ENDPOINT;\n $this->sink = $sink === null ? null : \\Closure::fromCallable($sink);\n if ($this->enabled && !self::$shutdownRegistered) {\n self::$shutdownRegistered = true;\n // There is no later window at shutdown, so a failed send drops its counts.\n register_shutdown_function(static function (): void {\n foreach (self::$openWindows as $telemetry) {\n $telemetry->flushWindow(false);\n }\n });\n }\n }\n\n public function __invoke(array $call): void\n {\n if (!$this->enabled) {\n return;\n }\n $now = microtime(true);\n if ($this->counts !== [] && $now >= $this->nextSendAt) {\n $this->flushWindow(true);\n // The send can take up to SEND_TIMEOUT_MS, so the next window starts after it.\n $now = microtime(true);\n }\n if ($this->counts === []) {\n $this->windowStart = $now;\n $this->nextSendAt = $now + $this->flushIntervalMs / 1000;\n self::$openWindows[spl_object_id($this)] = $this;\n }\n $operationId = $call['operationId'];\n $operation = $this->counts[$operationId] ?? self::emptyCounts();\n $operation['calls'] += 1;\n $operation['attempts'] += $call['attempts'];\n $operation['durationSumMs'] += $call['durationMs'];\n $status = self::statusClass($call);\n $operation['status'][$status] = ($operation['status'][$status] ?? 0) + 1;\n foreach (self::LATENCY_BUCKETS as $bucket => $belowMs) {\n if ($call['durationMs'] < $belowMs) {\n $operation['latency'][$bucket] = ($operation['latency'][$bucket] ?? 0) + 1;\n break;\n }\n }\n $this->counts[$operationId] = $operation;\n }\n\n /** Send the open window now, for example before `exit` in a long-running worker. */\n public function flush(): void\n {\n $this->flushWindow(true);\n }\n\n /** Count the calls of every client of the namespace, also of a client created before. */\n public function register(): void\n {\n Config::$registeredOnSettled[] = $this;\n self::$registered[] = $this;\n }\n\n /** Flush every instance that `register` added, as `flush` does. */\n public static function flushRegistered(): void\n {\n foreach (self::$registered as $telemetry) {\n $telemetry->flush();\n }\n }\n\n private static function disabled(): bool\n {\n return getenv('REDOCLY_SDK_TELEMETRY') === 'off' || getenv('DO_NOT_TRACK') === '1';\n }\n\n private static function emptyCounts(): array\n {\n return ['calls' => 0, 'attempts' => 0, 'durationSumMs' => 0, 'status' => [], 'latency' => []];\n }\n\n private static function statusClass(array $call): string\n {\n if ($call['timedOut']) {\n return 'timeout';\n }\n $status = $call['status'];\n if ($status === 0) {\n return 'network_error';\n }\n if ($status >= 500) {\n return '5xx';\n }\n if ($status >= 400) {\n return '4xx';\n }\n if ($status >= 300) {\n return '3xx';\n }\n return '2xx';\n }\n\n private function flushWindow(bool $keepOnFailure): void\n {\n if ($this->counts === []) {\n return;\n }\n $sentCounts = $this->counts;\n $sentStart = $this->windowStart;\n $this->counts = [];\n unset(self::$openWindows[spl_object_id($this)]);\n if (self::disabled()) {\n return;\n }\n $events = $this->toEvents($sentCounts, $sentStart, microtime(true) - $sentStart);\n if ($this->deliver($events) !== 'keep' || !$keepOnFailure) {\n return;\n }\n // PHP is single-threaded, so no call was recorded during the send.\n $this->counts = $sentCounts;\n $this->windowStart = $sentStart;\n $this->nextSendAt = microtime(true) + $this->flushIntervalMs / 1000;\n self::$openWindows[spl_object_id($this)] = $this;\n }\n\n /** `sent`, or what to do with the counts after a failed send: `keep` or `drop`. */\n private function deliver(array $events): string\n {\n if ($this->sink !== null) {\n try {\n ($this->sink)($events);\n return 'sent';\n } catch (\\Throwable) {\n return 'keep';\n }\n }\n $handle = curl_init($this->endpoint);\n curl_setopt_array($handle, [\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode(self::toOtlpRequest($events)),\n // One send per window, so the connection is never reused.\n CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Connection: close'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_TIMEOUT_MS => self::SEND_TIMEOUT_MS,\n ]);\n if (curl_exec($handle) === false) {\n // The endpoint can have stored the counts of a send that timed out.\n return curl_errno($handle) === CURLE_OPERATION_TIMEDOUT ? 'drop' : 'keep';\n }\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n if ($status >= 200 && $status < 300) {\n return 'sent';\n }\n return $status === 408 || $status === 429 || $status >= 500 ? 'keep' : 'drop';\n }\n\n private function toEvents(array $counts, float $windowStart, float $windowSeconds): array\n {\n $milliseconds = (int) (($windowStart - floor($windowStart)) * 1000);\n $time = gmdate('Y-m-d\\TH:i:s', (int) $windowStart) . sprintf('.%03dZ', $milliseconds);\n $events = [];\n foreach ($counts as $operationId => $operation) {\n $usage = [\n 'operation_id' => (string) $operationId,\n 'window_ms' => (int) ($windowSeconds * 1000),\n 'calls' => $operation['calls'],\n 'attempts' => $operation['attempts'],\n 'duration_sum_ms' => $operation['durationSumMs'],\n ];\n foreach (self::STATUS_CLASSES as $status) {\n if (isset($operation['status'][$status])) {\n $usage[\"status_{$status}\"] = $operation['status'][$status];\n }\n }\n foreach (array_keys(self::LATENCY_BUCKETS) as $bucket) {\n if (isset($operation['latency'][$bucket])) {\n $usage[\"latency_{$bucket}\"] = $operation['latency'][$bucket];\n }\n }\n $events[] = [\n 'id' => 'evt_' . bin2hex(random_bytes(16)),\n 'type' => self::EVENT_TYPE,\n 'source' => $this->sdk['name'],\n 'time' => $time,\n 'data' => ['usage' => $usage, 'sdk' => $this->sdk],\n ];\n }\n return $events;\n }\n\n private static function toOtlpRequest(array $events): array\n {\n $spans = [];\n foreach ($events as $event) {\n $started = \\DateTimeImmutable::createFromFormat('Y-m-d\\TH:i:s.v\\Z', $event['time'], new \\DateTimeZone('UTC'));\n $timeUnixNano = $started->format('Uv') . '000000';\n $attributes = [\n self::attribute('cloudevents.event_id', $event['id']),\n self::attribute('cloudevents.event_type', $event['type']),\n self::attribute('cloudevents.event_source', $event['source']),\n self::attribute('cloudevents.event_spec_version', '1.0'),\n self::attribute('cloudevents.event_data_content_type', 'application/json; charset=utf-8'),\n self::attribute('cloudevents.event_time', $event['time']),\n self::attribute('cloudevents.event_subject', ''),\n self::attribute('cloudevents.event_origin', self::SERVICE_NAME),\n self::attribute('cloudevents.event_object', 'event'),\n self::attribute('cloudevents.event_category', 'product'),\n self::attribute('cloudevents.event_signal', 'metric'),\n ];\n foreach ($event['data']['usage'] as $key => $value) {\n $attributes[] = self::attribute(\"cloudevents.event_data.usage.{$key}\", $value);\n }\n foreach ($event['data']['sdk'] as $key => $value) {\n $attributes[] = self::attribute(\"cloudevents.event_data.sdk.{$key}\", $value);\n }\n $spans[] = [\n 'traceId' => bin2hex(random_bytes(16)),\n 'spanId' => bin2hex(random_bytes(8)),\n 'name' => 'event.' . $event['type'],\n 'kind' => 1,\n 'startTimeUnixNano' => $timeUnixNano,\n 'endTimeUnixNano' => $timeUnixNano,\n 'attributes' => $attributes,\n ];\n }\n return [\n 'resourceSpans' => [\n [\n 'resource' => [\n 'attributes' => [\n self::attribute('service.name', self::SERVICE_NAME),\n self::attribute('service.version', self::GENERATOR_VERSION),\n ],\n ],\n 'scopeSpans' => [['scope' => ['name' => 'redocly-sdk-telemetry'], 'spans' => $spans]],\n ],\n ],\n ];\n }\n\n private static function attribute(string $key, string|int|float $value): array\n {\n if (is_string($value)) {\n return ['key' => $key, 'value' => ['stringValue' => $value]];\n }\n if (is_int($value)) {\n return ['key' => $key, 'value' => ['intValue' => $value]];\n }\n return ['key' => $key, 'value' => ['doubleValue' => $value]];\n }\n}\n"; + " 100,\n 'lt_300ms' => 300,\n 'lt_1s' => 1000,\n 'lt_3s' => 3000,\n 'lt_10s' => 10000,\n 'gte_10s' => PHP_INT_MAX,\n ];\n\n private bool $enabled;\n private array $sdk;\n private string $endpoint;\n private ?\\Closure $sink;\n /** @var array The instances with an open window, by object id. */\n private static array $openWindows = [];\n private static bool $shutdownRegistered = false;\n /** @var array The instances that `register` added. */\n private static array $registered = [];\n\n private array $counts = [];\n private float $windowStart = 0.0;\n private float $nextSendAt = 0.0;\n\n public function __construct(\n string $sdkName,\n string $sdkVersion,\n string $client,\n ?callable $sink = null,\n ?string $endpoint = null,\n private int $flushIntervalMs = 60_000,\n ) {\n $this->enabled = !self::disabled();\n $this->sdk = [\n 'id' => $sdkName,\n 'object' => 'sdk',\n 'uri' => self::SDK_URN_PREFIX . $sdkName,\n 'version' => $sdkVersion,\n 'generator_version' => self::GENERATOR_VERSION,\n 'language' => 'php',\n 'runtime' => 'php',\n 'runtime_version' => PHP_VERSION,\n 'client' => $client,\n ];\n $this->endpoint = $endpoint ?? self::DEFAULT_ENDPOINT;\n $this->sink = $sink === null ? null : \\Closure::fromCallable($sink);\n if ($this->enabled && !self::$shutdownRegistered) {\n self::$shutdownRegistered = true;\n // There is no later window at shutdown, so a failed send drops its counts.\n register_shutdown_function(static function (): void {\n foreach (self::$openWindows as $telemetry) {\n $telemetry->flushWindow(false);\n }\n });\n }\n }\n\n public function __invoke(array $call): void\n {\n if (!$this->enabled) {\n return;\n }\n $now = microtime(true);\n if ($this->counts !== [] && $now >= $this->nextSendAt) {\n $this->flushWindow(true);\n // The send can take up to SEND_TIMEOUT_MS, so the next window starts after it.\n $now = microtime(true);\n }\n if ($this->counts === []) {\n $this->windowStart = $now;\n $this->nextSendAt = $now + $this->flushIntervalMs / 1000;\n self::$openWindows[spl_object_id($this)] = $this;\n }\n $operationId = $call['operationId'];\n $operation = $this->counts[$operationId] ?? self::emptyCounts();\n $operation['calls'] += 1;\n $operation['attempts'] += $call['attempts'];\n $operation['durationSumMs'] += $call['durationMs'];\n $status = self::statusClass($call);\n $operation['status'][$status] = ($operation['status'][$status] ?? 0) + 1;\n foreach (self::LATENCY_BUCKETS as $bucket => $belowMs) {\n if ($call['durationMs'] < $belowMs) {\n $operation['latency'][$bucket] = ($operation['latency'][$bucket] ?? 0) + 1;\n break;\n }\n }\n $this->counts[$operationId] = $operation;\n }\n\n /** Send the open window now, for example before `exit` in a long-running worker. */\n public function flush(): void\n {\n $this->flushWindow(true);\n }\n\n /** Count the calls of every client of the namespace, also of a client created before. */\n public function register(): void\n {\n Config::$registeredOnSettled[] = $this;\n self::$registered[] = $this;\n }\n\n /** Flush every instance that `register` added, as `flush` does. */\n public static function flushRegistered(): void\n {\n foreach (self::$registered as $telemetry) {\n $telemetry->flush();\n }\n }\n\n private static function disabled(): bool\n {\n return getenv('REDOCLY_SDK_TELEMETRY') === 'off' || getenv('DO_NOT_TRACK') === '1';\n }\n\n private static function emptyCounts(): array\n {\n return ['calls' => 0, 'attempts' => 0, 'durationSumMs' => 0, 'status' => [], 'latency' => []];\n }\n\n private static function statusClass(array $call): string\n {\n if ($call['timedOut']) {\n return 'timeout';\n }\n $status = $call['status'];\n if ($status === 0) {\n return 'network_error';\n }\n if ($status >= 500) {\n return '5xx';\n }\n if ($status >= 400) {\n return '4xx';\n }\n if ($status >= 300) {\n return '3xx';\n }\n return '2xx';\n }\n\n private function flushWindow(bool $keepOnFailure): void\n {\n if ($this->counts === []) {\n return;\n }\n $sentCounts = $this->counts;\n $sentStart = $this->windowStart;\n $this->counts = [];\n unset(self::$openWindows[spl_object_id($this)]);\n if (self::disabled()) {\n return;\n }\n $events = $this->toEvents($sentCounts, $sentStart, microtime(true) - $sentStart);\n if ($this->deliver($events) !== 'keep' || !$keepOnFailure) {\n return;\n }\n // PHP is single-threaded, so no call was recorded during the send.\n $this->counts = $sentCounts;\n $this->windowStart = $sentStart;\n $this->nextSendAt = microtime(true) + $this->flushIntervalMs / 1000;\n self::$openWindows[spl_object_id($this)] = $this;\n }\n\n /** `sent`, or what to do with the counts after a failed send: `keep` or `drop`. */\n private function deliver(array $events): string\n {\n if ($this->sink !== null) {\n try {\n ($this->sink)($events);\n return 'sent';\n } catch (\\Throwable) {\n return 'keep';\n }\n }\n $handle = curl_init($this->endpoint);\n curl_setopt_array($handle, [\n CURLOPT_POST => true,\n CURLOPT_POSTFIELDS => json_encode(self::toOtlpRequest($events)),\n // One send per window, so the connection is never reused.\n CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Connection: close'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_TIMEOUT_MS => self::SEND_TIMEOUT_MS,\n ]);\n if (curl_exec($handle) === false) {\n // The endpoint can have stored the counts of a send that timed out.\n return curl_errno($handle) === CURLE_OPERATION_TIMEDOUT ? 'drop' : 'keep';\n }\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n if ($status >= 200 && $status < 300) {\n return 'sent';\n }\n return $status === 408 || $status === 429 || $status >= 500 ? 'keep' : 'drop';\n }\n\n private function toEvents(array $counts, float $windowStart, float $windowSeconds): array\n {\n $milliseconds = (int) (($windowStart - floor($windowStart)) * 1000);\n $time = gmdate('Y-m-d\\TH:i:s', (int) $windowStart) . sprintf('.%03dZ', $milliseconds);\n $events = [];\n foreach ($counts as $operationId => $operation) {\n $operationItem = [\n 'id' => (string) $operationId,\n 'object' => 'operation',\n 'uri' => $this->sdk['uri'] . ':operation:' . $operationId,\n 'window_ms' => (int) ($windowSeconds * 1000),\n 'calls' => $operation['calls'],\n 'attempts' => $operation['attempts'],\n 'duration_sum_ms' => $operation['durationSumMs'],\n ];\n foreach (self::STATUS_CLASSES as $status) {\n if (isset($operation['status'][$status])) {\n $operationItem[\"status_{$status}\"] = $operation['status'][$status];\n }\n }\n foreach (array_keys(self::LATENCY_BUCKETS) as $bucket) {\n if (isset($operation['latency'][$bucket])) {\n $operationItem[\"latency_{$bucket}\"] = $operation['latency'][$bucket];\n }\n }\n $events[] = [\n 'id' => self::eventId(),\n 'object' => 'event',\n 'specversion' => '1.0',\n 'datacontenttype' => 'application/json; charset=utf-8',\n 'type' => self::EVENT_TYPE,\n 'source' => 'urn:redocly:cli',\n 'time' => $time,\n 'origin' => 'redocly-sdk',\n 'category' => 'product',\n 'signal' => 'metric',\n 'data' => [$operationItem, $this->sdk],\n ];\n }\n return $events;\n }\n\n /** `evt_` and a lowercase ULID: 10 characters of the time in milliseconds, then 16 random ones. */\n private static function eventId(): string\n {\n $millis = (int) floor(microtime(true) * 1000);\n $id = '';\n for ($index = 0; $index < 10; $index++) {\n $id = self::CROCKFORD_BASE32[$millis % 32] . $id;\n $millis = intdiv($millis, 32);\n }\n foreach (str_split(random_bytes(16)) as $byte) {\n $id .= self::CROCKFORD_BASE32[ord($byte) % 32];\n }\n return 'evt_' . $id;\n }\n\n private static function toOtlpRequest(array $events): array\n {\n $spans = [];\n foreach ($events as $event) {\n $started = \\DateTimeImmutable::createFromFormat('Y-m-d\\TH:i:s.v\\Z', $event['time'], new \\DateTimeZone('UTC'));\n $timeUnixNano = $started->format('Uv') . '000000';\n // The attributes that processCloudEventAttributes of @redocly/cli-otel gives for the event.\n $attributes = [\n self::attribute('cloudevents.event_id', $event['id']),\n self::attribute('cloudevents.event_type', $event['type']),\n self::attribute('cloudevents.event_source', $event['source']),\n self::attribute('cloudevents.event_spec_version', $event['specversion']),\n self::attribute('cloudevents.event_data_content_type', $event['datacontenttype']),\n self::attribute('cloudevents.event_time', $event['time']),\n self::attribute('cloudevents.event_subject', $event['data'][0]['id']),\n self::attribute('cloudevents.event_origin', $event['origin']),\n self::attribute('cloudevents.event_object', $event['object']),\n self::attribute('cloudevents.event_category', $event['category']),\n self::attribute('cloudevents.event_signal', $event['signal']),\n self::attribute('cloudevents.event_request_id', ''),\n ];\n foreach ($event['data'] as $item) {\n foreach ($item as $key => $value) {\n $attributes[] = self::attribute(\"cloudevents.event_data.{$item['object']}.{$key}\", $value);\n }\n }\n foreach ($event['data'] as $item) {\n foreach (['id', 'object', 'uri'] as $key) {\n $attributes[] = self::attribute(\"cloudevents.event_subjects.{$item['object']}.{$key}\", $item[$key]);\n }\n }\n $spans[] = [\n 'traceId' => bin2hex(random_bytes(16)),\n 'spanId' => bin2hex(random_bytes(8)),\n 'name' => 'event.' . $event['type'],\n 'kind' => 1,\n 'startTimeUnixNano' => $timeUnixNano,\n 'endTimeUnixNano' => $timeUnixNano,\n 'attributes' => $attributes,\n ];\n }\n return [\n 'resourceSpans' => [\n [\n 'resource' => [\n 'attributes' => [\n self::attribute('service.name', self::SERVICE_NAME),\n self::attribute('service.version', self::GENERATOR_VERSION),\n ],\n ],\n 'scopeSpans' => [['scope' => ['name' => 'redocly-sdk-telemetry'], 'spans' => $spans]],\n ],\n ],\n ];\n }\n\n private static function attribute(string $key, string|int|float $value): array\n {\n if (is_string($value)) {\n return ['key' => $key, 'value' => ['stringValue' => $value]];\n }\n if (is_int($value)) {\n return ['key' => $key, 'value' => ['intValue' => $value]];\n }\n return ['key' => $key, 'value' => ['doubleValue' => $value]];\n }\n}\n"; export const PHP_TELEMETRY_DECLARED_NAMES = ['UsageTelemetry'] as const; diff --git a/packages/client-generator/src/runtime-sources/python-telemetry.ts b/packages/client-generator/src/runtime-sources/python-telemetry.ts index 2f6b2f55d3..237bdee650 100644 --- a/packages/client-generator/src/runtime-sources/python-telemetry.ts +++ b/packages/client-generator/src/runtime-sources/python-telemetry.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const PYTHON_TELEMETRY_SOURCE = - '# Usage telemetry for a generated Python SDK, written beside the client by\n# `client.telemetry: on`. It counts calls per operation through the runtime\'s\n# on_settled hook and sends one event per operation and window. An event holds\n# only the operation id, the counts, the window, and the SDK identity.\nfrom __future__ import annotations\n\nimport asyncio\nimport atexit\nimport json\nimport math\nimport os\nimport platform\nimport secrets\nimport threading\nimport time\nfrom datetime import datetime, timezone\nfrom typing import Any, Callable, Dict, List, Optional, Set\n\nimport httpx\n\nGENERATOR_VERSION = "dev"\n_DEFAULT_ENDPOINT = "https://otel.cloud.redocly.com/v1/traces"\n_EVENT_TYPE = "com.redocly.sdkUsage.aggregated"\n_SEND_TIMEOUT_S = 3.0\n_SERVICE_NAME = "redocly-sdk"\n_STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "network_error", "timeout", "aborted"]\n_LATENCY_BUCKETS = [\n ("lt_100ms", 100),\n ("lt_300ms", 300),\n ("lt_1s", 1000),\n ("lt_3s", 3000),\n ("lt_10s", 10000),\n ("gte_10s", math.inf),\n]\n\nSdkUsageEvent = Dict[str, Any]\nSink = Callable[[List[SdkUsageEvent]], None]\n\n\nclass _RejectedSend(Exception):\n def __init__(self, status: int) -> None:\n super().__init__(f"The telemetry endpoint answered {status}.")\n self.status = status\n\n\ndef _disabled() -> bool:\n return os.environ.get("REDOCLY_SDK_TELEMETRY") == "off" or os.environ.get("DO_NOT_TRACK") == "1"\n\n\ndef _empty_counts() -> Dict[str, Any]:\n return {"calls": 0, "attempts": 0, "duration_sum_ms": 0, "status": {}, "latency": {}}\n\n\nclass UsageTelemetry:\n """The middleware that `telemetry()` returns. Safe to share across threads and clients."""\n\n def __init__(self, sdk: Dict[str, str], sink: Sink, flush_interval_ms: int, enabled: bool) -> None:\n self._enabled = enabled\n self._sdk = sdk\n self._sink = sink\n self._interval_s = flush_interval_ms / 1000\n self._lock = threading.Lock()\n self._counts: Dict[str, Dict[str, Any]] = {}\n self._window_start = 0.0\n self._timer: Optional[threading.Timer] = None\n\n def on_settled(self, context: Dict[str, Any], call: Dict[str, Any]) -> None:\n if not self._enabled:\n return\n with self._lock:\n if not self._counts:\n self._open_window()\n operation = self._counts.setdefault(context["operation"]["id"], _empty_counts())\n duration_ms: int = call["duration_ms"]\n operation["calls"] += 1\n operation["attempts"] += call["attempts"]\n operation["duration_sum_ms"] += duration_ms\n status = _status_class(call)\n operation["status"][status] = operation["status"].get(status, 0) + 1\n for bucket, below_ms in _LATENCY_BUCKETS:\n if duration_ms < below_ms:\n operation["latency"][bucket] = operation["latency"].get(bucket, 0) + 1\n break\n\n def flush(self, keep_on_failure: bool = True) -> None:\n timer = self._timer\n self._flush_window(keep_on_failure)\n # A timer that already fired may still be sending its window.\n if timer is not None:\n timer.join(_SEND_TIMEOUT_S + 1)\n\n def _open_window(self) -> None:\n self._window_start = time.time()\n self._timer = threading.Timer(self._interval_s, self._flush_window)\n self._timer.daemon = True\n self._timer.start()\n with _open_windows_lock:\n _open_windows.add(self)\n\n def _flush_window(self, keep_on_failure: bool = True) -> None:\n with self._lock:\n if not self._counts:\n return\n if self._timer is not None:\n self._timer.cancel()\n sent_counts = self._counts\n sent_start = self._window_start\n self._counts = {}\n try:\n if not _disabled():\n self._sink(_to_events(sent_counts, sent_start, time.time() - sent_start, self._sdk))\n except Exception as error:\n if keep_on_failure and _keep_after(error):\n with self._lock:\n if not self._counts:\n self._open_window()\n self._window_start = min(self._window_start, sent_start)\n for operation_id, sent in sent_counts.items():\n operation = self._counts.setdefault(operation_id, _empty_counts())\n for key in ("calls", "attempts", "duration_sum_ms"):\n operation[key] += sent[key]\n for group in ("status", "latency"):\n for key, count in sent[group].items():\n operation[group][key] = operation[group].get(key, 0) + count\n with self._lock:\n # A call during the send can open the next window.\n if not self._counts:\n with _open_windows_lock:\n _open_windows.discard(self)\n\n\n# The instances with an open window or a send in progress.\n_open_windows: Set[UsageTelemetry] = set()\n_open_windows_lock = threading.Lock()\n\n\ndef _open_windows_now() -> List[UsageTelemetry]:\n with _open_windows_lock:\n return list(_open_windows)\n\n\ndef telemetry(\n *,\n sdk_name: str,\n sdk_version: str,\n client: str,\n sink: Optional[Sink] = None,\n endpoint: Optional[str] = None,\n flush_interval_ms: int = 60_000,\n) -> UsageTelemetry:\n """The usage telemetry middleware. REDOCLY_SDK_TELEMETRY=off or DO_NOT_TRACK=1 makes it record nothing."""\n target = endpoint or _DEFAULT_ENDPOINT\n sdk = {\n "name": sdk_name,\n "version": sdk_version,\n "generator_version": GENERATOR_VERSION,\n "language": "python",\n "runtime": platform.python_implementation().lower(),\n "runtime_version": platform.python_version(),\n "client": client,\n }\n enabled = not _disabled()\n return UsageTelemetry(sdk, sink or (lambda events: _send_otlp(target, events)), flush_interval_ms, enabled)\n\n\ndef flush() -> None:\n """Send every open window now and wait for the sends, for example before os._exit()."""\n for instance in _open_windows_now():\n instance.flush()\n\n\n@atexit.register\ndef _flush_at_exit() -> None:\n # There is no later window at exit, so a failed send drops its counts.\n for instance in _open_windows_now():\n instance.flush(keep_on_failure=False)\n\n\ndef _status_class(call: Dict[str, Any]) -> str:\n response: Optional[httpx.Response] = call.get("response")\n if response is None:\n error = call.get("error")\n # The runtime\'s own timeout error, matched by name: this module imports nothing from the client.\n if type(error).__name__ == "ApiTimeoutError":\n return "timeout"\n if isinstance(error, asyncio.CancelledError):\n return "aborted"\n return "network_error"\n if response.status_code >= 500:\n return "5xx"\n if response.status_code >= 400:\n return "4xx"\n if response.status_code >= 300:\n return "3xx"\n return "2xx"\n\n\ndef _keep_after(error: Exception) -> bool:\n # The endpoint can have stored the counts of a send that timed out.\n if isinstance(error, httpx.TimeoutException):\n return False\n if isinstance(error, _RejectedSend):\n return error.status in (408, 429) or error.status >= 500\n return True\n\n\ndef _to_events(\n counts: Dict[str, Dict[str, Any]], window_start: float, window_s: float, sdk: Dict[str, str]\n) -> List[SdkUsageEvent]:\n started = datetime.fromtimestamp(window_start, tz=timezone.utc)\n events: List[SdkUsageEvent] = []\n for operation_id, operation in counts.items():\n usage: Dict[str, Any] = {\n "operation_id": operation_id,\n "window_ms": int(window_s * 1000),\n "calls": operation["calls"],\n "attempts": operation["attempts"],\n "duration_sum_ms": operation["duration_sum_ms"],\n }\n for status in _STATUS_CLASSES:\n if status in operation["status"]:\n usage[f"status_{status}"] = operation["status"][status]\n for bucket, _ in _LATENCY_BUCKETS:\n if bucket in operation["latency"]:\n usage[f"latency_{bucket}"] = operation["latency"][bucket]\n events.append(\n {\n "id": f"evt_{secrets.token_hex(16)}",\n "type": _EVENT_TYPE,\n "source": sdk["name"],\n "time": started.isoformat(timespec="milliseconds").replace("+00:00", "Z"),\n "data": {"usage": usage, "sdk": sdk},\n }\n )\n return events\n\n\ndef _send_otlp(endpoint: str, events: List[SdkUsageEvent]) -> None:\n response = httpx.post(\n endpoint,\n content=json.dumps(_to_otlp_request(events)),\n # One send per window, so the connection is never reused.\n headers={"Content-Type": "application/json", "Connection": "close"},\n timeout=_SEND_TIMEOUT_S,\n )\n if not response.is_success:\n raise _RejectedSend(response.status_code)\n\n\ndef _to_otlp_request(events: List[SdkUsageEvent]) -> Dict[str, Any]:\n spans: List[Dict[str, Any]] = []\n for event in events:\n started = datetime.fromisoformat(event["time"].replace("Z", "+00:00"))\n time_unix_nano = f"{int(started.timestamp() * 1000)}000000"\n attributes = [\n _attribute("cloudevents.event_id", event["id"]),\n _attribute("cloudevents.event_type", event["type"]),\n _attribute("cloudevents.event_source", event["source"]),\n _attribute("cloudevents.event_spec_version", "1.0"),\n _attribute("cloudevents.event_data_content_type", "application/json; charset=utf-8"),\n _attribute("cloudevents.event_time", event["time"]),\n _attribute("cloudevents.event_subject", ""),\n _attribute("cloudevents.event_origin", _SERVICE_NAME),\n _attribute("cloudevents.event_object", "event"),\n _attribute("cloudevents.event_category", "product"),\n _attribute("cloudevents.event_signal", "metric"),\n ]\n for key, value in event["data"]["usage"].items():\n attributes.append(_attribute(f"cloudevents.event_data.usage.{key}", value))\n for key, value in event["data"]["sdk"].items():\n attributes.append(_attribute(f"cloudevents.event_data.sdk.{key}", value))\n spans.append(\n {\n "traceId": secrets.token_hex(16),\n "spanId": secrets.token_hex(8),\n "name": f"event.{event[\'type\']}",\n "kind": 1,\n "startTimeUnixNano": time_unix_nano,\n "endTimeUnixNano": time_unix_nano,\n "attributes": attributes,\n }\n )\n return {\n "resourceSpans": [\n {\n "resource": {\n "attributes": [\n _attribute("service.name", _SERVICE_NAME),\n _attribute("service.version", GENERATOR_VERSION),\n ]\n },\n "scopeSpans": [{"scope": {"name": "redocly-sdk-telemetry"}, "spans": spans}],\n }\n ]\n }\n\n\ndef _attribute(key: str, value: Any) -> Dict[str, Any]:\n if isinstance(value, str):\n return {"key": key, "value": {"stringValue": value}}\n if isinstance(value, int):\n return {"key": key, "value": {"intValue": value}}\n return {"key": key, "value": {"doubleValue": value}}\n'; + '# Usage telemetry for a generated Python SDK, written beside the client by\n# `client.telemetry: on`. It counts calls per operation through the runtime\'s\n# on_settled hook and sends one event per operation and window. An event holds\n# only the operation id, the counts, the window, and the SDK identity.\nfrom __future__ import annotations\n\nimport asyncio\nimport atexit\nimport json\nimport math\nimport os\nimport platform\nimport secrets\nimport threading\nimport time\nfrom datetime import datetime, timezone\nfrom typing import Any, Callable, Dict, List, Optional, Set\n\nimport httpx\n\nGENERATOR_VERSION = "dev"\n_DEFAULT_ENDPOINT = "https://otel.cloud.redocly.com/v1/traces"\n_EVENT_TYPE = "com.redocly.sdkUsage.aggregated"\n_SEND_TIMEOUT_S = 3.0\n_SERVICE_NAME = "redocly-sdk"\n_SDK_URN_PREFIX = "urn:redocly:cli:sdk:python:"\n_CROCKFORD_BASE32 = "0123456789abcdefghjkmnpqrstvwxyz"\n_STATUS_CLASSES = ["2xx", "3xx", "4xx", "5xx", "network_error", "timeout", "aborted"]\n_LATENCY_BUCKETS = [\n ("lt_100ms", 100),\n ("lt_300ms", 300),\n ("lt_1s", 1000),\n ("lt_3s", 3000),\n ("lt_10s", 10000),\n ("gte_10s", math.inf),\n]\n\n# A com.redocly.sdkUsage.aggregated CloudEvent. Its "data" holds the operation, then the SDK.\n# The OpenTelemetry collector sets the "env" of the event.\nSdkUsageEvent = Dict[str, Any]\nSink = Callable[[List[SdkUsageEvent]], None]\n\n\nclass _RejectedSend(Exception):\n def __init__(self, status: int) -> None:\n super().__init__(f"The telemetry endpoint answered {status}.")\n self.status = status\n\n\ndef _disabled() -> bool:\n return os.environ.get("REDOCLY_SDK_TELEMETRY") == "off" or os.environ.get("DO_NOT_TRACK") == "1"\n\n\ndef _empty_counts() -> Dict[str, Any]:\n return {"calls": 0, "attempts": 0, "duration_sum_ms": 0, "status": {}, "latency": {}}\n\n\nclass UsageTelemetry:\n """The middleware that `telemetry()` returns. Safe to share across threads and clients."""\n\n def __init__(self, sdk: Dict[str, Any], sink: Sink, flush_interval_ms: int, enabled: bool) -> None:\n self._enabled = enabled\n self._sdk = sdk\n self._sink = sink\n self._interval_s = flush_interval_ms / 1000\n self._lock = threading.Lock()\n self._counts: Dict[str, Dict[str, Any]] = {}\n self._window_start = 0.0\n self._timer: Optional[threading.Timer] = None\n\n def on_settled(self, context: Dict[str, Any], call: Dict[str, Any]) -> None:\n if not self._enabled:\n return\n with self._lock:\n if not self._counts:\n self._open_window()\n operation = self._counts.setdefault(context["operation"]["id"], _empty_counts())\n duration_ms: int = call["duration_ms"]\n operation["calls"] += 1\n operation["attempts"] += call["attempts"]\n operation["duration_sum_ms"] += duration_ms\n status = _status_class(call)\n operation["status"][status] = operation["status"].get(status, 0) + 1\n for bucket, below_ms in _LATENCY_BUCKETS:\n if duration_ms < below_ms:\n operation["latency"][bucket] = operation["latency"].get(bucket, 0) + 1\n break\n\n def flush(self, keep_on_failure: bool = True) -> None:\n timer = self._timer\n self._flush_window(keep_on_failure)\n # A timer that already fired may still be sending its window.\n if timer is not None:\n timer.join(_SEND_TIMEOUT_S + 1)\n\n def _open_window(self) -> None:\n self._window_start = time.time()\n self._timer = threading.Timer(self._interval_s, self._flush_window)\n self._timer.daemon = True\n self._timer.start()\n with _open_windows_lock:\n _open_windows.add(self)\n\n def _flush_window(self, keep_on_failure: bool = True) -> None:\n with self._lock:\n if not self._counts:\n return\n if self._timer is not None:\n self._timer.cancel()\n sent_counts = self._counts\n sent_start = self._window_start\n self._counts = {}\n try:\n if not _disabled():\n self._sink(_to_events(sent_counts, sent_start, time.time() - sent_start, self._sdk))\n except Exception as error:\n if keep_on_failure and _keep_after(error):\n with self._lock:\n if not self._counts:\n self._open_window()\n self._window_start = min(self._window_start, sent_start)\n for operation_id, sent in sent_counts.items():\n operation = self._counts.setdefault(operation_id, _empty_counts())\n for key in ("calls", "attempts", "duration_sum_ms"):\n operation[key] += sent[key]\n for group in ("status", "latency"):\n for key, count in sent[group].items():\n operation[group][key] = operation[group].get(key, 0) + count\n with self._lock:\n # A call during the send can open the next window.\n if not self._counts:\n with _open_windows_lock:\n _open_windows.discard(self)\n\n\n# The instances with an open window or a send in progress.\n_open_windows: Set[UsageTelemetry] = set()\n_open_windows_lock = threading.Lock()\n\n\ndef _open_windows_now() -> List[UsageTelemetry]:\n with _open_windows_lock:\n return list(_open_windows)\n\n\ndef telemetry(\n *,\n sdk_name: str,\n sdk_version: str,\n client: str,\n sink: Optional[Sink] = None,\n endpoint: Optional[str] = None,\n flush_interval_ms: int = 60_000,\n) -> UsageTelemetry:\n """The usage telemetry middleware. REDOCLY_SDK_TELEMETRY=off or DO_NOT_TRACK=1 makes it record nothing."""\n target = endpoint or _DEFAULT_ENDPOINT\n sdk: Dict[str, Any] = {\n "id": sdk_name,\n "object": "sdk",\n "uri": f"{_SDK_URN_PREFIX}{sdk_name}",\n "version": sdk_version,\n "generator_version": GENERATOR_VERSION,\n "language": "python",\n "runtime": platform.python_implementation().lower(),\n "runtime_version": platform.python_version(),\n "client": client,\n }\n enabled = not _disabled()\n return UsageTelemetry(sdk, sink or (lambda events: _send_otlp(target, events)), flush_interval_ms, enabled)\n\n\ndef flush() -> None:\n """Send every open window now and wait for the sends, for example before os._exit()."""\n for instance in _open_windows_now():\n instance.flush()\n\n\n@atexit.register\ndef _flush_at_exit() -> None:\n # There is no later window at exit, so a failed send drops its counts.\n for instance in _open_windows_now():\n instance.flush(keep_on_failure=False)\n\n\ndef _status_class(call: Dict[str, Any]) -> str:\n response: Optional[httpx.Response] = call.get("response")\n if response is None:\n error = call.get("error")\n # The runtime\'s own timeout error, matched by name: this module imports nothing from the client.\n if type(error).__name__ == "ApiTimeoutError":\n return "timeout"\n if isinstance(error, asyncio.CancelledError):\n return "aborted"\n return "network_error"\n if response.status_code >= 500:\n return "5xx"\n if response.status_code >= 400:\n return "4xx"\n if response.status_code >= 300:\n return "3xx"\n return "2xx"\n\n\ndef _keep_after(error: Exception) -> bool:\n # The endpoint can have stored the counts of a send that timed out.\n if isinstance(error, httpx.TimeoutException):\n return False\n if isinstance(error, _RejectedSend):\n return error.status in (408, 429) or error.status >= 500\n return True\n\n\ndef _to_events(\n counts: Dict[str, Dict[str, Any]], window_start: float, window_s: float, sdk: Dict[str, Any]\n) -> List[SdkUsageEvent]:\n started = datetime.fromtimestamp(window_start, tz=timezone.utc)\n events: List[SdkUsageEvent] = []\n for operation_id, operation in counts.items():\n operation_item: Dict[str, Any] = {\n "id": operation_id,\n "object": "operation",\n "uri": f"{sdk[\'uri\']}:operation:{operation_id}",\n "window_ms": int(window_s * 1000),\n "calls": operation["calls"],\n "attempts": operation["attempts"],\n "duration_sum_ms": operation["duration_sum_ms"],\n }\n for status in _STATUS_CLASSES:\n if status in operation["status"]:\n operation_item[f"status_{status}"] = operation["status"][status]\n for bucket, _ in _LATENCY_BUCKETS:\n if bucket in operation["latency"]:\n operation_item[f"latency_{bucket}"] = operation["latency"][bucket]\n events.append(\n {\n "id": _event_id(),\n "object": "event",\n "specversion": "1.0",\n "datacontenttype": "application/json; charset=utf-8",\n "type": _EVENT_TYPE,\n "source": "urn:redocly:cli",\n "time": started.isoformat(timespec="milliseconds").replace("+00:00", "Z"),\n "origin": "redocly-sdk",\n "category": "product",\n "signal": "metric",\n "data": [operation_item, sdk],\n }\n )\n return events\n\n\ndef _event_id() -> str:\n """evt_ and a lowercase ULID: 10 characters of the time in milliseconds, then 16 random ones."""\n millis = time.time_ns() // 1_000_000\n time_part = ""\n for _ in range(10):\n time_part = _CROCKFORD_BASE32[millis % 32] + time_part\n millis //= 32\n random_part = ""\n for byte in secrets.token_bytes(16):\n random_part += _CROCKFORD_BASE32[byte % 32]\n return f"evt_{time_part}{random_part}"\n\n\ndef _send_otlp(endpoint: str, events: List[SdkUsageEvent]) -> None:\n response = httpx.post(\n endpoint,\n content=json.dumps(_to_otlp_request(events)),\n # One send per window, so the connection is never reused.\n headers={"Content-Type": "application/json", "Connection": "close"},\n timeout=_SEND_TIMEOUT_S,\n )\n if not response.is_success:\n raise _RejectedSend(response.status_code)\n\n\ndef _to_otlp_request(events: List[SdkUsageEvent]) -> Dict[str, Any]:\n spans: List[Dict[str, Any]] = []\n for event in events:\n started = datetime.fromisoformat(event["time"].replace("Z", "+00:00"))\n time_unix_nano = f"{int(started.timestamp() * 1000)}000000"\n # The attributes that processCloudEventAttributes of @redocly/cli-otel gives for the event.\n attributes = [\n _attribute("cloudevents.event_id", event["id"]),\n _attribute("cloudevents.event_type", event["type"]),\n _attribute("cloudevents.event_source", event["source"]),\n _attribute("cloudevents.event_spec_version", event["specversion"]),\n _attribute("cloudevents.event_data_content_type", event["datacontenttype"]),\n _attribute("cloudevents.event_time", event["time"]),\n _attribute("cloudevents.event_subject", event["data"][0]["id"]),\n _attribute("cloudevents.event_origin", event["origin"]),\n _attribute("cloudevents.event_object", event["object"]),\n _attribute("cloudevents.event_category", event["category"]),\n _attribute("cloudevents.event_signal", event["signal"]),\n _attribute("cloudevents.event_request_id", ""),\n ]\n for item in event["data"]:\n for key, value in item.items():\n attributes.append(_attribute(f"cloudevents.event_data.{item[\'object\']}.{key}", value))\n for item in event["data"]:\n for key in ("id", "object", "uri"):\n attributes.append(_attribute(f"cloudevents.event_subjects.{item[\'object\']}.{key}", item[key]))\n spans.append(\n {\n "traceId": secrets.token_hex(16),\n "spanId": secrets.token_hex(8),\n "name": f"event.{event[\'type\']}",\n "kind": 1,\n "startTimeUnixNano": time_unix_nano,\n "endTimeUnixNano": time_unix_nano,\n "attributes": attributes,\n }\n )\n return {\n "resourceSpans": [\n {\n "resource": {\n "attributes": [\n _attribute("service.name", _SERVICE_NAME),\n _attribute("service.version", GENERATOR_VERSION),\n ]\n },\n "scopeSpans": [{"scope": {"name": "redocly-sdk-telemetry"}, "spans": spans}],\n }\n ]\n }\n\n\ndef _attribute(key: str, value: Any) -> Dict[str, Any]:\n if isinstance(value, str):\n return {"key": key, "value": {"stringValue": value}}\n if isinstance(value, int):\n return {"key": key, "value": {"intValue": value}}\n return {"key": key, "value": {"doubleValue": value}}\n'; diff --git a/packages/client-generator/src/runtime-sources/telemetry.ts b/packages/client-generator/src/runtime-sources/telemetry.ts index a74890840e..6d6c107ee6 100644 --- a/packages/client-generator/src/runtime-sources/telemetry.ts +++ b/packages/client-generator/src/runtime-sources/telemetry.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const TELEMETRY_RUNTIME_SOURCE = - "import type { Middleware, SettledCall } from '../../../runtime-contract.js';\n\nconst GENERATOR_VERSION: string = 'dev';\nconst DEFAULT_ENDPOINT = 'https://otel.cloud.redocly.com/v1/traces';\nconst EVENT_TYPE = 'com.redocly.sdkUsage.aggregated';\nconst SEND_TIMEOUT_MS = 3000;\nconst SERVICE_NAME = 'redocly-sdk';\n\ntype StatusClass = '2xx' | '3xx' | '4xx' | '5xx' | 'network_error' | 'timeout' | 'aborted';\ntype LatencyBucket = 'lt_100ms' | 'lt_300ms' | 'lt_1s' | 'lt_3s' | 'lt_10s' | 'gte_10s';\n\nconst STATUS_CLASSES: StatusClass[] = [\n '2xx',\n '3xx',\n '4xx',\n '5xx',\n 'network_error',\n 'timeout',\n 'aborted',\n];\nconst LATENCY_BUCKETS: Array<{ bucket: LatencyBucket; belowMs: number }> = [\n { bucket: 'lt_100ms', belowMs: 100 },\n { bucket: 'lt_300ms', belowMs: 300 },\n { bucket: 'lt_1s', belowMs: 1000 },\n { bucket: 'lt_3s', belowMs: 3000 },\n { bucket: 'lt_10s', belowMs: 10000 },\n { bucket: 'gte_10s', belowMs: Infinity },\n];\n\nexport type SdkUsageEvent = {\n id: string;\n type: typeof EVENT_TYPE;\n source: string;\n time: string;\n data: {\n usage: Record;\n sdk: Record;\n };\n};\n\nexport type TelemetryOptions = {\n sdkName: string;\n sdkVersion: string;\n client: string;\n sink?: (events: SdkUsageEvent[]) => void | Promise;\n endpoint?: string;\n flushIntervalMs?: number;\n};\n\ntype OperationCounts = {\n calls: number;\n attempts: number;\n durationSumMs: number;\n status: Record;\n latency: Record;\n};\n\ntype ProcessLike = {\n env?: Record;\n versions?: { node?: string; bun?: string; deno?: string };\n on?: (event: 'beforeExit', listener: () => void) => void;\n};\n\ntype OnFailedSend = 'keep' | 'drop';\n\nconst activeWindows = new Set<(onFailedSend: OnFailedSend) => Promise>();\nconst pendingSends = new Set>();\nlet exitHookInstalled = false;\n\nfunction nodeProcess(): ProcessLike | undefined {\n return (globalThis as { process?: ProcessLike }).process;\n}\n\nfunction telemetryDisabled(): boolean {\n const runtimeProcess = nodeProcess();\n if (runtimeProcess === undefined) return true;\n // A browser bundle can add a `process` shim, so also check for `document`.\n if (typeof document !== 'undefined') return true;\n const env = runtimeProcess.env ?? {};\n if (env.REDOCLY_SDK_TELEMETRY === 'off') return true;\n if (env.DO_NOT_TRACK === '1') return true;\n return false;\n}\n\nexport function flush(): Promise {\n return flushWindows('keep');\n}\n\nasync function flushWindows(onFailedSend: OnFailedSend): Promise {\n for (const flushWindow of activeWindows) void flushWindow(onFailedSend);\n await Promise.all(pendingSends);\n}\n\nexport function telemetry(options: TelemetryOptions): Middleware {\n if (telemetryDisabled()) return {};\n const intervalMs = options.flushIntervalMs ?? 60_000;\n const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;\n const sink = options.sink ?? ((events: SdkUsageEvent[]) => sendOtlp(endpoint, events));\n const versions = nodeProcess()?.versions ?? {};\n let runtime = 'node';\n let runtimeVersion = versions.node ?? '';\n if (versions.bun !== undefined) {\n runtime = 'bun';\n runtimeVersion = versions.bun;\n } else if (versions.deno !== undefined) {\n runtime = 'deno';\n runtimeVersion = versions.deno;\n }\n const sdk: Record = {\n name: options.sdkName,\n version: options.sdkVersion,\n generator_version: GENERATOR_VERSION,\n language: 'typescript',\n runtime,\n runtime_version: runtimeVersion,\n client: options.client,\n };\n let counts = new Map();\n let windowStart = 0;\n let timer: ReturnType | undefined;\n\n function openWindow(): void {\n windowStart = Date.now();\n timer = setTimeout(() => void flushWindow('keep'), intervalMs);\n unrefTimer(timer);\n activeWindows.add(flushWindow);\n }\n\n async function flushWindow(onFailedSend: OnFailedSend): Promise {\n if (counts.size === 0) return;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n activeWindows.delete(flushWindow);\n const events = toEvents(counts, windowStart, Date.now() - windowStart, options.sdkName, sdk);\n const sentCounts = counts;\n const sentWindowStart = windowStart;\n counts = new Map();\n if (telemetryDisabled()) return;\n let action: OnFailedSend | undefined;\n const send = Promise.resolve()\n .then(() => sink(events))\n .catch((error: unknown) => {\n action = actionForFailedSend(error);\n });\n pendingSends.add(send);\n await send;\n pendingSends.delete(send);\n if (action !== 'keep' || onFailedSend === 'drop') return;\n\n if (counts.size === 0) openWindow();\n if (sentWindowStart < windowStart) windowStart = sentWindowStart;\n for (const [operationId, sentOperation] of sentCounts) {\n const operation = counts.get(operationId);\n if (operation === undefined) {\n counts.set(operationId, sentOperation);\n } else {\n addCounts(operation, sentOperation);\n }\n }\n }\n\n installExitHook();\n\n return {\n onSettled: (ctx, call) => {\n if (counts.size === 0) openWindow();\n let operation = counts.get(ctx.operation.id);\n if (operation === undefined) {\n operation = emptyCounts();\n counts.set(ctx.operation.id, operation);\n }\n record(operation, call);\n },\n };\n}\n\nfunction installExitHook(): void {\n if (exitHookInstalled) return;\n exitHookInstalled = true;\n // Node emits `beforeExit` again after each send at exit.\n // Kept counts then cause one more send each time, so a failed send here drops its counts.\n nodeProcess()?.on?.('beforeExit', () => void flushWindows('drop'));\n}\n\nfunction unrefTimer(timer: unknown): void {\n if (timer instanceof Object && 'unref' in timer && typeof timer.unref === 'function') {\n timer.unref();\n }\n}\n\nfunction emptyCounts(): OperationCounts {\n return {\n calls: 0,\n attempts: 0,\n durationSumMs: 0,\n status: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, network_error: 0, timeout: 0, aborted: 0 },\n latency: { lt_100ms: 0, lt_300ms: 0, lt_1s: 0, lt_3s: 0, lt_10s: 0, gte_10s: 0 },\n };\n}\n\nfunction record(operation: OperationCounts, call: SettledCall): void {\n operation.calls += 1;\n // A call aborted before its first attempt settles with 0 attempts. It counts as 1, as in the\n // other languages, so the attempts of an event are never fewer than its calls.\n operation.attempts += Math.max(call.attempts, 1);\n operation.durationSumMs += call.durationMs;\n operation.status[statusClass(call)] += 1;\n for (const { bucket, belowMs } of LATENCY_BUCKETS) {\n if (call.durationMs < belowMs) {\n operation.latency[bucket] += 1;\n break;\n }\n }\n}\n\nfunction addCounts(target: OperationCounts, source: OperationCounts): void {\n target.calls += source.calls;\n target.attempts += source.attempts;\n target.durationSumMs += source.durationSumMs;\n for (const statusClass of STATUS_CLASSES) {\n target.status[statusClass] += source.status[statusClass];\n }\n for (const { bucket } of LATENCY_BUCKETS) {\n target.latency[bucket] += source.latency[bucket];\n }\n}\n\nfunction statusClass(call: SettledCall): StatusClass {\n if (call.response === undefined) {\n if (call.error instanceof Error && call.error.name === 'TimeoutError') return 'timeout';\n if (call.error instanceof Error && call.error.name === 'AbortError') return 'aborted';\n return 'network_error';\n }\n const status = call.response.status;\n if (status >= 500) return '5xx';\n if (status >= 400) return '4xx';\n if (status >= 300) return '3xx';\n return '2xx';\n}\n\nfunction toEvents(\n counts: Map,\n windowStart: number,\n windowMs: number,\n source: string,\n sdk: Record\n): SdkUsageEvent[] {\n const events: SdkUsageEvent[] = [];\n for (const [operationId, operation] of counts) {\n const usage: Record = {\n operation_id: operationId,\n window_ms: windowMs,\n calls: operation.calls,\n attempts: operation.attempts,\n duration_sum_ms: operation.durationSumMs,\n };\n for (const statusClass of STATUS_CLASSES) {\n const count = operation.status[statusClass];\n if (count > 0) {\n usage[`status_${statusClass}`] = count;\n }\n }\n for (const { bucket } of LATENCY_BUCKETS) {\n const count = operation.latency[bucket];\n if (count > 0) {\n usage[`latency_${bucket}`] = count;\n }\n }\n events.push({\n id: `evt_${randomHex(16)}`,\n type: EVENT_TYPE,\n source,\n time: new Date(windowStart).toISOString(),\n data: { usage, sdk },\n });\n }\n return events;\n}\n\nasync function sendOtlp(endpoint: string, events: SdkUsageEvent[]): Promise {\n const response = await fetch(endpoint, {\n method: 'POST',\n // One send per window, so the socket is never reused: close it instead of keeping it idle.\n headers: { 'Content-Type': 'application/json', Connection: 'close' },\n body: JSON.stringify(toOtlpRequest(events)),\n signal: AbortSignal.timeout(SEND_TIMEOUT_MS),\n });\n await response.body?.cancel();\n if (!response.ok) {\n throw new RejectedSendError(response.status);\n }\n}\n\nclass RejectedSendError extends Error {\n public readonly status: number;\n constructor(status: number) {\n super(`The telemetry endpoint answered ${status}.`);\n this.name = 'RejectedSendError';\n this.status = status;\n }\n}\n\nfunction actionForFailedSend(error: unknown): OnFailedSend {\n // The endpoint can have stored the counts of a send that timed out.\n if (error instanceof Error && error.name === 'TimeoutError') return 'drop';\n if (error instanceof RejectedSendError) {\n if (error.status === 408 || error.status === 429 || error.status >= 500) return 'keep';\n // The endpoint refuses the same counts again.\n return 'drop';\n }\n return 'keep';\n}\n\ntype OtlpValue = { stringValue: string } | { intValue: number } | { doubleValue: number };\ntype OtlpAttribute = { key: string; value: OtlpValue };\n\nfunction toOtlpRequest(events: SdkUsageEvent[]) {\n const spans = [];\n for (const event of events) {\n const timeUnixNano = `${Date.parse(event.time)}000000`;\n const attributes: OtlpAttribute[] = [\n attribute('cloudevents.event_id', event.id),\n attribute('cloudevents.event_type', event.type),\n attribute('cloudevents.event_source', event.source),\n attribute('cloudevents.event_spec_version', '1.0'),\n attribute('cloudevents.event_data_content_type', 'application/json; charset=utf-8'),\n attribute('cloudevents.event_time', event.time),\n attribute('cloudevents.event_subject', ''),\n attribute('cloudevents.event_origin', SERVICE_NAME),\n attribute('cloudevents.event_object', 'event'),\n attribute('cloudevents.event_category', 'product'),\n attribute('cloudevents.event_signal', 'metric'),\n ];\n for (const [key, value] of Object.entries(event.data.usage)) {\n attributes.push(attribute(`cloudevents.event_data.usage.${key}`, value));\n }\n for (const [key, value] of Object.entries(event.data.sdk)) {\n attributes.push(attribute(`cloudevents.event_data.sdk.${key}`, value));\n }\n spans.push({\n traceId: randomHex(16),\n spanId: randomHex(8),\n name: `event.${event.type}`,\n kind: 1,\n startTimeUnixNano: timeUnixNano,\n endTimeUnixNano: timeUnixNano,\n attributes,\n });\n }\n return {\n resourceSpans: [\n {\n resource: {\n attributes: [\n attribute('service.name', SERVICE_NAME),\n attribute('service.version', GENERATOR_VERSION),\n ],\n },\n scopeSpans: [{ scope: { name: 'redocly-sdk-telemetry' }, spans }],\n },\n ],\n };\n}\n\nfunction attribute(key: string, value: string | number): OtlpAttribute {\n if (typeof value === 'string') return { key, value: { stringValue: value } };\n if (Number.isInteger(value)) return { key, value: { intValue: value } };\n return { key, value: { doubleValue: value } };\n}\n\nfunction randomHex(byteCount: number): string {\n const bytes = crypto.getRandomValues(new Uint8Array(byteCount));\n let hex = '';\n for (const byte of bytes) hex += byte.toString(16).padStart(2, '0');\n return hex;\n}\n"; + "import type { Middleware, SettledCall } from '../../../runtime-contract.js';\n\nconst GENERATOR_VERSION: string = 'dev';\nconst DEFAULT_ENDPOINT = 'https://otel.cloud.redocly.com/v1/traces';\nconst EVENT_TYPE = 'com.redocly.sdkUsage.aggregated';\nconst SEND_TIMEOUT_MS = 3000;\nconst SERVICE_NAME = 'redocly-sdk';\nconst CROCKFORD_BASE32 = '0123456789abcdefghjkmnpqrstvwxyz';\n\ntype StatusClass = '2xx' | '3xx' | '4xx' | '5xx' | 'network_error' | 'timeout' | 'aborted';\ntype LatencyBucket = 'lt_100ms' | 'lt_300ms' | 'lt_1s' | 'lt_3s' | 'lt_10s' | 'gte_10s';\n\nconst STATUS_CLASSES: StatusClass[] = [\n '2xx',\n '3xx',\n '4xx',\n '5xx',\n 'network_error',\n 'timeout',\n 'aborted',\n];\nconst LATENCY_BUCKETS: Array<{ bucket: LatencyBucket; belowMs: number }> = [\n { bucket: 'lt_100ms', belowMs: 100 },\n { bucket: 'lt_300ms', belowMs: 300 },\n { bucket: 'lt_1s', belowMs: 1000 },\n { bucket: 'lt_3s', belowMs: 3000 },\n { bucket: 'lt_10s', belowMs: 10000 },\n { bucket: 'gte_10s', belowMs: Infinity },\n];\n\nexport type SdkUsageAggregatedMessage = BaseEventMessage & {\n type: \"com.redocly.sdkUsage.aggregated\";\n category: \"product\";\n signal: \"metric\";\n /**\n * The calls of one API operation through one generated SDK in one usage window. An event holds exactly one operation, so a usage window with several operations sends one event per operation, each with the same SDK item.\n *\n * @minItems 2\n * @maxItems 2\n */\n data: [\n {\n /**\n * The `operationId` of the operation, or the name that the generator gives to an operation without an `operationId`.\n */\n id: string;\n /**\n * Type of the resource associated with this event.\n */\n object: \"operation\";\n /**\n * Canonical URN of the operation in the SDK (`urn:redocly:cli:sdk:::operation:`).\n */\n uri: string;\n /**\n * Length of the usage window in milliseconds.\n */\n window_ms: number;\n /**\n * Number of calls of the operation in the usage window.\n */\n calls: number;\n /**\n * Number of HTTP attempts, retries included. It is never less than `calls`.\n */\n attempts: number;\n /**\n * Sum of the call durations in milliseconds.\n */\n duration_sum_ms: number;\n /**\n * Number of calls that ended with a 2xx response.\n */\n status_2xx?: number;\n /**\n * Number of calls that ended with a 3xx response.\n */\n status_3xx?: number;\n /**\n * Number of calls that ended with a 4xx response.\n */\n status_4xx?: number;\n /**\n * Number of calls that ended with a 5xx response.\n */\n status_5xx?: number;\n /**\n * Number of calls that ended without a response because of a network error.\n */\n status_network_error?: number;\n /**\n * Number of calls that ended because of a timeout.\n */\n status_timeout?: number;\n /**\n * Number of calls that the application aborted.\n */\n status_aborted?: number;\n /**\n * Number of calls that took less than 100 ms.\n */\n latency_lt_100ms?: number;\n /**\n * Number of calls that took 100 ms or more and less than 300 ms.\n */\n latency_lt_300ms?: number;\n /**\n * Number of calls that took 300 ms or more and less than 1 s.\n */\n latency_lt_1s?: number;\n /**\n * Number of calls that took 1 s or more and less than 3 s.\n */\n latency_lt_3s?: number;\n /**\n * Number of calls that took 3 s or more and less than 10 s.\n */\n latency_lt_10s?: number;\n /**\n * Number of calls that took 10 s or more.\n */\n latency_gte_10s?: number;\n },\n {\n /**\n * The package name of the SDK.\n */\n id: string;\n /**\n * Type of the resource associated with this event.\n */\n object: \"sdk\";\n /**\n * Canonical URN of the SDK package (`urn:redocly:cli:sdk::`). A package name is unique only in the registry of one language.\n */\n uri: string;\n /**\n * The version of the SDK package.\n */\n version: string;\n /**\n * The version of the Redocly client generator that generated the SDK. Redocly CLI contains the generator.\n */\n generator_version: string;\n /**\n * The language of the SDK.\n */\n language: \"typescript\" | \"python\" | \"go\" | \"php\";\n /**\n * The runtime that runs the SDK. TypeScript sends `node`, `bun`, or `deno`. Python sends the implementation in lowercase, for example `cpython`. Go sends `go` and PHP sends `php`.\n */\n runtime: string;\n /**\n * The version of the runtime.\n */\n runtime_version: string;\n /**\n * The name and version of the application that uses the SDK, as the application registered them.\n */\n client: string;\n }\n ];\n};\n\nexport interface BaseEventMessage {\n /**\n * Event ID.\n */\n id: string;\n /**\n * Object type.\n */\n object: \"event\";\n /**\n * CloudEvents specification version.\n */\n specversion: \"1.0\";\n /**\n * Data content type.\n */\n datacontenttype: \"application/json; charset=utf-8\";\n /**\n * Context in which the event happened (producer component), not the actor and not the page.\n * Format: urn:redocly:{product}:{component}\n *\n */\n source: \"urn:redocly:cli\";\n actor?: null | {\n /**\n * Anonymous Id of the user that the event is associated with, doesn't contain any personal data.\n */\n id?: string;\n /**\n * Type of the object that the event is associated with.\n */\n object?: \"user\";\n /**\n * URL of the user, for CLI events it will be empty since we don't have user profiles in the CLI.\n */\n uri?: string;\n };\n /**\n * Timestamp of when the event happened.\n */\n time: string;\n /**\n * Origin of the event. `redocly-cli` is a CLI command. `redocly-sdk` is an SDK that Redocly CLI generated.\n */\n origin: \"redocly-cli\" | \"redocly-sdk\";\n /**\n * Telemetry signal kind: log (fire-and-forget event), trace (correlated span), or metric (aggregated counts).\n */\n signal?: \"log\" | \"trace\" | \"metric\";\n /**\n * Operating system platform.\n */\n osPlatform?: string;\n /**\n * Deployment environment. When the producer does not set it, the OpenTelemetry collector sets `otel_collector_`, so every stored event has it.\n */\n env: string;\n}\n\n/** The `com.redocly.sdkUsage.aggregated` CloudEvent. The OpenTelemetry collector sets `env`. */\nexport type SdkUsageEvent = Omit;\ntype OperationItem = SdkUsageEvent['data'][0];\ntype SdkItem = SdkUsageEvent['data'][1];\n\nexport type TelemetryOptions = {\n sdkName: string;\n sdkVersion: string;\n client: string;\n sink?: (events: SdkUsageEvent[]) => void | Promise;\n endpoint?: string;\n flushIntervalMs?: number;\n};\n\ntype OperationCounts = {\n calls: number;\n attempts: number;\n durationSumMs: number;\n status: Record;\n latency: Record;\n};\n\ntype ProcessLike = {\n env?: Record;\n versions?: { node?: string; bun?: string; deno?: string };\n on?: (event: 'beforeExit', listener: () => void) => void;\n};\n\ntype OnFailedSend = 'keep' | 'drop';\n\nconst activeWindows = new Set<(onFailedSend: OnFailedSend) => Promise>();\nconst pendingSends = new Set>();\nlet exitHookInstalled = false;\n\nfunction nodeProcess(): ProcessLike | undefined {\n return (globalThis as { process?: ProcessLike }).process;\n}\n\nfunction telemetryDisabled(): boolean {\n const runtimeProcess = nodeProcess();\n if (runtimeProcess === undefined) return true;\n // A browser bundle can add a `process` shim, so also check for `document`.\n if (typeof document !== 'undefined') return true;\n const env = runtimeProcess.env ?? {};\n if (env.REDOCLY_SDK_TELEMETRY === 'off') return true;\n if (env.DO_NOT_TRACK === '1') return true;\n return false;\n}\n\nexport function flush(): Promise {\n return flushWindows('keep');\n}\n\nasync function flushWindows(onFailedSend: OnFailedSend): Promise {\n for (const flushWindow of activeWindows) void flushWindow(onFailedSend);\n await Promise.all(pendingSends);\n}\n\nexport function telemetry(options: TelemetryOptions): Middleware {\n if (telemetryDisabled()) return {};\n const intervalMs = options.flushIntervalMs ?? 60_000;\n const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;\n const sink = options.sink ?? ((events: SdkUsageEvent[]) => sendOtlp(endpoint, events));\n const versions = nodeProcess()?.versions ?? {};\n let runtime = 'node';\n let runtimeVersion = versions.node ?? '';\n if (versions.bun !== undefined) {\n runtime = 'bun';\n runtimeVersion = versions.bun;\n } else if (versions.deno !== undefined) {\n runtime = 'deno';\n runtimeVersion = versions.deno;\n }\n const sdk: SdkItem = {\n id: options.sdkName,\n object: 'sdk',\n uri: `urn:redocly:cli:sdk:typescript:${options.sdkName}`,\n version: options.sdkVersion,\n generator_version: GENERATOR_VERSION,\n language: 'typescript',\n runtime,\n runtime_version: runtimeVersion,\n client: options.client,\n };\n let counts = new Map();\n let windowStart = 0;\n let timer: ReturnType | undefined;\n\n function openWindow(): void {\n windowStart = Date.now();\n timer = setTimeout(() => void flushWindow('keep'), intervalMs);\n unrefTimer(timer);\n activeWindows.add(flushWindow);\n }\n\n async function flushWindow(onFailedSend: OnFailedSend): Promise {\n if (counts.size === 0) return;\n if (timer !== undefined) clearTimeout(timer);\n timer = undefined;\n activeWindows.delete(flushWindow);\n const events = toEvents(counts, windowStart, Date.now() - windowStart, sdk);\n const sentCounts = counts;\n const sentWindowStart = windowStart;\n counts = new Map();\n if (telemetryDisabled()) return;\n let action: OnFailedSend | undefined;\n const send = Promise.resolve()\n .then(() => sink(events))\n .catch((error: unknown) => {\n action = actionForFailedSend(error);\n });\n pendingSends.add(send);\n await send;\n pendingSends.delete(send);\n if (action !== 'keep' || onFailedSend === 'drop') return;\n\n if (counts.size === 0) openWindow();\n if (sentWindowStart < windowStart) windowStart = sentWindowStart;\n for (const [operationId, sentOperation] of sentCounts) {\n const operation = counts.get(operationId);\n if (operation === undefined) {\n counts.set(operationId, sentOperation);\n } else {\n addCounts(operation, sentOperation);\n }\n }\n }\n\n installExitHook();\n\n return {\n onSettled: (ctx, call) => {\n if (counts.size === 0) openWindow();\n let operation = counts.get(ctx.operation.id);\n if (operation === undefined) {\n operation = emptyCounts();\n counts.set(ctx.operation.id, operation);\n }\n record(operation, call);\n },\n };\n}\n\nfunction installExitHook(): void {\n if (exitHookInstalled) return;\n exitHookInstalled = true;\n // Node emits `beforeExit` again after each send at exit.\n // Kept counts then cause one more send each time, so a failed send here drops its counts.\n nodeProcess()?.on?.('beforeExit', () => void flushWindows('drop'));\n}\n\nfunction unrefTimer(timer: unknown): void {\n if (timer instanceof Object && 'unref' in timer && typeof timer.unref === 'function') {\n timer.unref();\n }\n}\n\nfunction emptyCounts(): OperationCounts {\n return {\n calls: 0,\n attempts: 0,\n durationSumMs: 0,\n status: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, network_error: 0, timeout: 0, aborted: 0 },\n latency: { lt_100ms: 0, lt_300ms: 0, lt_1s: 0, lt_3s: 0, lt_10s: 0, gte_10s: 0 },\n };\n}\n\nfunction record(operation: OperationCounts, call: SettledCall): void {\n operation.calls += 1;\n // A call aborted before its first attempt settles with 0 attempts. It counts as 1, as in the\n // other languages, so the attempts of an event are never fewer than its calls.\n operation.attempts += Math.max(call.attempts, 1);\n operation.durationSumMs += call.durationMs;\n operation.status[statusClass(call)] += 1;\n for (const { bucket, belowMs } of LATENCY_BUCKETS) {\n if (call.durationMs < belowMs) {\n operation.latency[bucket] += 1;\n break;\n }\n }\n}\n\nfunction addCounts(target: OperationCounts, source: OperationCounts): void {\n target.calls += source.calls;\n target.attempts += source.attempts;\n target.durationSumMs += source.durationSumMs;\n for (const statusClass of STATUS_CLASSES) {\n target.status[statusClass] += source.status[statusClass];\n }\n for (const { bucket } of LATENCY_BUCKETS) {\n target.latency[bucket] += source.latency[bucket];\n }\n}\n\nfunction statusClass(call: SettledCall): StatusClass {\n if (call.response === undefined) {\n if (call.error instanceof Error && call.error.name === 'TimeoutError') return 'timeout';\n if (call.error instanceof Error && call.error.name === 'AbortError') return 'aborted';\n return 'network_error';\n }\n const status = call.response.status;\n if (status >= 500) return '5xx';\n if (status >= 400) return '4xx';\n if (status >= 300) return '3xx';\n return '2xx';\n}\n\nfunction toEvents(\n counts: Map,\n windowStart: number,\n windowMs: number,\n sdk: SdkItem\n): SdkUsageEvent[] {\n const events: SdkUsageEvent[] = [];\n for (const [operationId, operation] of counts) {\n const operationItem: OperationItem = {\n id: operationId,\n object: 'operation',\n uri: `${sdk.uri}:operation:${operationId}`,\n window_ms: windowMs,\n calls: operation.calls,\n attempts: operation.attempts,\n duration_sum_ms: operation.durationSumMs,\n };\n for (const statusClass of STATUS_CLASSES) {\n const count = operation.status[statusClass];\n if (count > 0) {\n operationItem[`status_${statusClass}`] = count;\n }\n }\n for (const { bucket } of LATENCY_BUCKETS) {\n const count = operation.latency[bucket];\n if (count > 0) {\n operationItem[`latency_${bucket}`] = count;\n }\n }\n events.push({\n id: eventId(),\n object: 'event',\n specversion: '1.0',\n datacontenttype: 'application/json; charset=utf-8',\n type: EVENT_TYPE,\n source: 'urn:redocly:cli',\n time: new Date(windowStart).toISOString(),\n origin: 'redocly-sdk',\n category: 'product',\n signal: 'metric',\n data: [operationItem, sdk],\n });\n }\n return events;\n}\n\n/** `evt_` and a lowercase ULID: 10 characters of the time in milliseconds, then 16 random ones. */\nfunction eventId(): string {\n let time = Date.now();\n let id = '';\n for (let index = 0; index < 10; index++) {\n id = CROCKFORD_BASE32[time % 32] + id;\n time = Math.floor(time / 32);\n }\n for (const byte of crypto.getRandomValues(new Uint8Array(16))) {\n id += CROCKFORD_BASE32[byte % 32];\n }\n return `evt_${id}`;\n}\n\nasync function sendOtlp(endpoint: string, events: SdkUsageEvent[]): Promise {\n const response = await fetch(endpoint, {\n method: 'POST',\n // One send per window, so the socket is never reused: close it instead of keeping it idle.\n headers: { 'Content-Type': 'application/json', Connection: 'close' },\n body: JSON.stringify(toOtlpRequest(events)),\n signal: AbortSignal.timeout(SEND_TIMEOUT_MS),\n });\n await response.body?.cancel();\n if (!response.ok) {\n throw new RejectedSendError(response.status);\n }\n}\n\nclass RejectedSendError extends Error {\n public readonly status: number;\n constructor(status: number) {\n super(`The telemetry endpoint answered ${status}.`);\n this.name = 'RejectedSendError';\n this.status = status;\n }\n}\n\nfunction actionForFailedSend(error: unknown): OnFailedSend {\n // The endpoint can have stored the counts of a send that timed out.\n if (error instanceof Error && error.name === 'TimeoutError') return 'drop';\n if (error instanceof RejectedSendError) {\n if (error.status === 408 || error.status === 429 || error.status >= 500) return 'keep';\n // The endpoint refuses the same counts again.\n return 'drop';\n }\n return 'keep';\n}\n\ntype OtlpValue = { stringValue: string } | { intValue: number } | { doubleValue: number };\ntype OtlpAttribute = { key: string; value: OtlpValue };\n\nfunction toOtlpRequest(events: SdkUsageEvent[]) {\n const spans = [];\n for (const event of events) {\n const timeUnixNano = `${Date.parse(event.time)}000000`;\n // The attributes that `processCloudEventAttributes` of @redocly/cli-otel gives for the event.\n const attributes: OtlpAttribute[] = [\n attribute('cloudevents.event_id', event.id),\n attribute('cloudevents.event_type', event.type),\n attribute('cloudevents.event_source', event.source),\n attribute('cloudevents.event_spec_version', event.specversion),\n attribute('cloudevents.event_data_content_type', event.datacontenttype),\n attribute('cloudevents.event_time', event.time),\n attribute('cloudevents.event_subject', event.data[0].id),\n attribute('cloudevents.event_origin', event.origin),\n attribute('cloudevents.event_object', event.object),\n attribute('cloudevents.event_category', event.category),\n attribute('cloudevents.event_signal', event.signal),\n attribute('cloudevents.event_request_id', ''),\n ];\n for (const item of event.data) {\n for (const [key, value] of Object.entries(item)) {\n if (value !== undefined) {\n attributes.push(attribute(`cloudevents.event_data.${item.object}.${key}`, value));\n }\n }\n }\n for (const item of event.data) {\n attributes.push(attribute(`cloudevents.event_subjects.${item.object}.id`, item.id));\n attributes.push(attribute(`cloudevents.event_subjects.${item.object}.object`, item.object));\n attributes.push(attribute(`cloudevents.event_subjects.${item.object}.uri`, item.uri));\n }\n spans.push({\n traceId: randomHex(16),\n spanId: randomHex(8),\n name: `event.${event.type}`,\n kind: 1,\n startTimeUnixNano: timeUnixNano,\n endTimeUnixNano: timeUnixNano,\n attributes,\n });\n }\n return {\n resourceSpans: [\n {\n resource: {\n attributes: [\n attribute('service.name', SERVICE_NAME),\n attribute('service.version', GENERATOR_VERSION),\n ],\n },\n scopeSpans: [{ scope: { name: 'redocly-sdk-telemetry' }, spans }],\n },\n ],\n };\n}\n\nfunction attribute(key: string, value: string | number): OtlpAttribute {\n if (typeof value === 'string') return { key, value: { stringValue: value } };\n if (Number.isInteger(value)) return { key, value: { intValue: value } };\n return { key, value: { doubleValue: value } };\n}\n\nfunction randomHex(byteCount: number): string {\n const bytes = crypto.getRandomValues(new Uint8Array(byteCount));\n let hex = '';\n for (const byte of bytes) hex += byte.toString(16).padStart(2, '0');\n return hex;\n}\n"; diff --git a/packages/client-generator/tests/e2e/go/telemetry.test.ts b/packages/client-generator/tests/e2e/go/telemetry.test.ts index abaa38e5ee..4b8b635f3f 100644 --- a/packages/client-generator/tests/e2e/go/telemetry.test.ts +++ b/packages/client-generator/tests/e2e/go/telemetry.test.ts @@ -46,7 +46,11 @@ describe("generate-client go generator, telemetry: 'on' against a local OTLP sin [503, 200] ); expect(requests).toHaveLength(2); - expect(requests[1][0].usage).toMatchObject({ operation_id: 'listPets', calls: 2, attempts: 3 }); + expect(requests[1][0]).toMatchObject({ + 'cloudevents.event_data.operation.id': 'listPets', + 'cloudevents.event_data.operation.calls': 2, + 'cloudevents.event_data.operation.attempts': 3, + }); }); describeTelemetryRuns( diff --git a/packages/client-generator/tests/e2e/helpers/telemetry-sink.ts b/packages/client-generator/tests/e2e/helpers/telemetry-sink.ts index 530a3bcf3f..5bb7ab3ae0 100644 --- a/packages/client-generator/tests/e2e/helpers/telemetry-sink.ts +++ b/packages/client-generator/tests/e2e/helpers/telemetry-sink.ts @@ -1,7 +1,8 @@ // An OTLP/HTTP traces endpoint for the usage telemetry tests. It answers the n-th request // with the n-th status of its list (the last one repeats), and keeps every request body. -// The test decodes each body back into the events the TypeScript telemetry module would -// hand a sink. +// The test decodes each span into its attributes and compares them with what +// @redocly/cli-otel, the owner of the event schema, gives for the expected event. +import { CloudEvents, processCloudEventAttributes } from '@redocly/cli-otel'; import { GENERATOR_VERSION } from '@redocly/client-generator'; import * as http from 'node:http'; import type { AddressInfo } from 'node:net'; @@ -13,13 +14,8 @@ import { MockServer } from './mock-server.js'; const fixture = join(dirname(fileURLToPath(import.meta.url)), '../fixtures/base.yaml'); -type UsageEvent = { - type: string; - source: string; - time: string; - usage: Record; - sdk: Record; -}; +/** The attributes of one span, by key. */ +type SpanAttributes = Record; type OtlpValue = { stringValue?: string; intValue?: number }; type OtlpRequest = { @@ -30,30 +26,7 @@ type OtlpRequest = { }>; }; -// The keys every event carries in every language, in the TypeScript module's order. -const EVENT_ATTRIBUTE_KEYS = [ - 'cloudevents.event_id', - 'cloudevents.event_type', - 'cloudevents.event_source', - 'cloudevents.event_spec_version', - 'cloudevents.event_data_content_type', - 'cloudevents.event_time', - 'cloudevents.event_subject', - 'cloudevents.event_origin', - 'cloudevents.event_object', - 'cloudevents.event_category', - 'cloudevents.event_signal', -]; -const USAGE_KEYS = ['operation_id', 'window_ms', 'calls', 'attempts', 'duration_sum_ms']; -const SDK_KEYS = [ - 'name', - 'version', - 'generator_version', - 'language', - 'runtime', - 'runtime_version', - 'client', -]; +const OPERATION_PREFIX = 'cloudevents.event_data.operation.'; class TelemetrySink { /** The body of each request, in arrival order. */ @@ -90,67 +63,78 @@ class TelemetrySink { } } -function decode(body: OtlpRequest): UsageEvent[] { - const events: UsageEvent[] = []; +function decode(body: OtlpRequest): SpanAttributes[] { + const events: SpanAttributes[] = []; for (const span of body.resourceSpans[0].scopeSpans[0].spans) { - const keys: string[] = []; - const event: UsageEvent = { type: '', source: '', time: '', usage: {}, sdk: {} }; + const attributes: SpanAttributes = {}; for (const { key, value } of span.attributes) { - const decoded = value.stringValue ?? value.intValue ?? ''; - if (key.startsWith('cloudevents.event_data.usage.')) { - event.usage[key.slice('cloudevents.event_data.usage.'.length)] = decoded; - } else if (key.startsWith('cloudevents.event_data.sdk.')) { - event.sdk[key.slice('cloudevents.event_data.sdk.'.length)] = String(decoded); - } else { - keys.push(key); - } - if (key === 'cloudevents.event_type') event.type = String(decoded); - if (key === 'cloudevents.event_source') event.source = String(decoded); - if (key === 'cloudevents.event_time') event.time = String(decoded); + attributes[key] = value.stringValue ?? value.intValue ?? ''; } - expect(keys.sort()).toEqual([...EVENT_ATTRIBUTE_KEYS].sort()); - events.push(event); + events.push(attributes); } return events; } /** * The event of two `listPets` calls, the first retried once after a `503`, registered as - * `@acme/pets-sdk` 1.0.0 by `pets-worker/2.0.0`: the same shape in every language. + * `@acme/pets-sdk` 1.0.0 by `pets-worker/2.0.0`: the same attributes in every language. */ -function expectListPetsEvent(event: UsageEvent, language: string, runtime: string): void { - expect(event.type).toBe('com.redocly.sdkUsage.aggregated'); - expect(event.source).toBe('@acme/pets-sdk'); - expect(event.time).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/); - - const outcomeKeys = Object.keys(event.usage).filter( - (key) => key.startsWith('status_') || key.startsWith('latency_') - ); - expect(Object.keys(event.usage).sort()).toEqual([...USAGE_KEYS, ...outcomeKeys].sort()); - expect(event.usage).toMatchObject({ - operation_id: 'listPets', - calls: 2, - attempts: 3, - status_2xx: 2, - }); +function expectListPetsEvent(sent: SpanAttributes, language: string, runtime: string): void { + expect(sent['cloudevents.event_id']).toMatch(/^evt_[0-9abcdefghjkmnpqrstvwxyz]{26}$/); + expect(sent['cloudevents.event_time']).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/); + expect(typeof sent[`${OPERATION_PREFIX}window_ms`]).toBe('number'); + expect(typeof sent[`${OPERATION_PREFIX}duration_sum_ms`]).toBe('number'); + expect(sent['cloudevents.event_data.sdk.runtime_version']).not.toBe(''); + + // The run decides the window, the durations, and so the duration buckets of the calls. + const latency: SpanAttributes = {}; let latencyCalls = 0; - for (const key of outcomeKeys) { - if (key.startsWith('latency_')) latencyCalls += Number(event.usage[key]); + for (const [key, value] of Object.entries(sent)) { + if (key.startsWith(`${OPERATION_PREFIX}latency_`)) { + latency[key.slice(OPERATION_PREFIX.length)] = value; + latencyCalls += Number(value); + } } expect(latencyCalls).toBe(2); - expect(typeof event.usage.window_ms).toBe('number'); - expect(typeof event.usage.duration_sum_ms).toBe('number'); - - expect(Object.keys(event.sdk).sort()).toEqual([...SDK_KEYS].sort()); - expect(event.sdk).toMatchObject({ - name: '@acme/pets-sdk', - version: '1.0.0', - generator_version: GENERATOR_VERSION, - language, - runtime, - client: 'pets-worker/2.0.0', + + const sdkUri = `urn:redocly:cli:sdk:${language}:@acme/pets-sdk`; + const event = CloudEvents.mapToCloudEvent({ + type: 'com.redocly.sdkUsage.aggregated', + source: 'urn:redocly:cli', + origin: 'redocly-sdk', + category: 'product', + signal: 'metric', + data: [ + { + id: 'listPets', + object: 'operation', + uri: `${sdkUri}:operation:listPets`, + window_ms: sent[`${OPERATION_PREFIX}window_ms`], + calls: 2, + attempts: 3, + duration_sum_ms: sent[`${OPERATION_PREFIX}duration_sum_ms`], + status_2xx: 2, + ...latency, + }, + { + id: '@acme/pets-sdk', + object: 'sdk', + uri: sdkUri, + version: '1.0.0', + generator_version: GENERATOR_VERSION, + language, + runtime, + runtime_version: sent['cloudevents.event_data.sdk.runtime_version'], + client: 'pets-worker/2.0.0', + }, + ], }); - expect(event.sdk.runtime_version).not.toBe(''); + const expected = processCloudEventAttributes( + { ...event, id: sent['cloudevents.event_id'] }, + new Date(sent['cloudevents.event_time']) + ); + // `toEqual` skips the attributes that the mapper leaves undefined, such as `env`. + expect(sent).toEqual(expected); } /** @@ -163,7 +147,7 @@ export async function runTelemetryConsumer( args: (serverUrl: string, sinkUrl: string) => string[], statuses: number[], env: Record = {} -): Promise { +): Promise { let listPetsAttempts = 0; const server = await MockServer.start(fixture, { handlers: { @@ -181,7 +165,7 @@ export async function runTelemetryConsumer( }); expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); expect(server.handlerErrors).toEqual([]); - const requests: UsageEvent[][] = []; + const requests: SpanAttributes[][] = []; for (const body of sink.requests) { requests.push(decode(JSON.parse(body))); } @@ -194,7 +178,7 @@ export async function runTelemetryConsumer( /** The three runs every language test makes: a kept send, a dropped send, and the opt-out. */ export function describeTelemetryRuns( - run: (statuses: number[], env?: Record) => Promise, + run: (statuses: number[], env?: Record) => Promise, language: string, runtime: string ): void { @@ -205,7 +189,7 @@ export function describeTelemetryRuns( expect(events).toHaveLength(1); expectListPetsEvent(events[0], language, runtime); } - expect(requests[1][0].time).toBe(requests[0][0].time); + expect(requests[1][0]['cloudevents.event_time']).toBe(requests[0][0]['cloudevents.event_time']); }); it('drops the counts of a send answered 400', async () => { diff --git a/packages/client-generator/tests/e2e/php/telemetry.test.ts b/packages/client-generator/tests/e2e/php/telemetry.test.ts index 0a67c55450..66279307a7 100644 --- a/packages/client-generator/tests/e2e/php/telemetry.test.ts +++ b/packages/client-generator/tests/e2e/php/telemetry.test.ts @@ -51,7 +51,11 @@ describe("generate-client php generator, telemetry: 'on' against a local OTLP si [503, 200] ); expect(requests).toHaveLength(2); - expect(requests[1][0].usage).toMatchObject({ operation_id: 'listPets', calls: 2, attempts: 3 }); + expect(requests[1][0]).toMatchObject({ + 'cloudevents.event_data.operation.id': 'listPets', + 'cloudevents.event_data.operation.calls': 2, + 'cloudevents.event_data.operation.attempts': 3, + }); }); describeTelemetryRuns( diff --git a/packages/client-generator/tests/e2e/typescript/telemetry/telemetry.test.ts b/packages/client-generator/tests/e2e/typescript/telemetry/telemetry.test.ts index 2da4930df6..aeae1d66c7 100644 --- a/packages/client-generator/tests/e2e/typescript/telemetry/telemetry.test.ts +++ b/packages/client-generator/tests/e2e/typescript/telemetry/telemetry.test.ts @@ -27,20 +27,28 @@ describe("telemetry: 'on'", () => { const result = (await runConsumer(join(dir, 'sink.ts'))) as { received: Array<{ type: string; - data: { usage: Record; sdk: Record }; + source: string; + origin: string; + data: [Record, Record]; }>; }; expect(result.received).toHaveLength(1); - expect(result.received[0].type).toBe('com.redocly.sdkUsage.aggregated'); - expect(result.received[0].data.usage).toMatchObject({ - operation_id: 'listPets', + expect(result.received[0]).toMatchObject({ + type: 'com.redocly.sdkUsage.aggregated', + source: 'urn:redocly:cli', + origin: 'redocly-sdk', + }); + expect(result.received[0].data[0]).toMatchObject({ + id: 'listPets', + object: 'operation', calls: 2, attempts: 2, status_2xx: 2, }); - expect(result.received[0].data.sdk).toMatchObject({ - name: '@acme/pets-sdk', + expect(result.received[0].data[1]).toMatchObject({ + id: '@acme/pets-sdk', + object: 'sdk', client: 'pets-worker/2.0.0', language: 'typescript', });