feat: add vendor extensions metric with per-extension counts - #3021
feat: add vendor extensions metric with per-extension counts#3021n0rahh wants to merge 24 commits into
Conversation
🦋 Changeset detectedLatest commit: 66b983c The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Performance Benchmark (Lower is Faster)
|
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1785934483 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1785934483 |
… to $ref and ignore map keys starting with x-
|
I like this idea |
…unts and new test cases
…for vendor extensions
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 561ede2. Configure here.
… in OpenAPI specs
|
📦 A new experimental 🧪 version v0.0.0-snapshot.1786099785 of Redocly CLI has been published for testing. Install with NPM: npm install @redocly/cli@0.0.0-snapshot.1786099785 |
|
I found things I didn't know existed in there. Thank you. |
| webhooks: { metric: '🎣 Webhooks', total: 0, color: 'green' }, | ||
| operations: { metric: '👷 Operations', total: 0, color: 'yellow' }, | ||
| tags: { metric: '🔖 Tags', total: 0, color: 'white', items: new Set() }, | ||
| xExtensions: { metric: '🧩 Vendor Extensions', total: 0, color: 'cyan' }, |
There was a problem hiding this comment.
Just saw we have items inside the tags accumulator (one line above). What if we arrange extensions similarly to tags? Is there something else we are going to show in the output except of the extension items and their count?
There was a problem hiding this comment.
We'd need a Map (a Set can't do keyed count++ per occurrence), which turns items into Set | Map<string, {count, props}> and pushes type-narrowing into every consumer — plus a Map isn't JSON-serializable for the JSON printer and the portal collector. Keeping items as "distinct names, size = total" and putting the per-extension payload in details seemed cleaner for me
| Operation: { | ||
| enter(operation: Oas3Operation, ctx: UserContext) { | ||
| if (ctx.key === 'x-query') { | ||
| collectSpecExtension(extensions, 'x-query', operation); |
There was a problem hiding this comment.
Why do we need to treat x-query differently? Isn't it already a SpecExtension?
I see, it has its own type.
| SharedResponse: | ||
| description: ok | ||
| schemas: | ||
| x-MySchema: |
There was a problem hiding this comment.
This one (as well as x-trace-id) shouldn't be counted as an extension, right?
There was a problem hiding this comment.
Yes, it just counts as a schema name
|
|
||
| currentLocation = resolvedLocation; | ||
| const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); | ||
| const seenKey = type === SpecExtension ? location.absolutePointer : resolvedNode; |
There was a problem hiding this comment.
SpecExtension nodes are often equal scalars (true, "internal"), and deduping them by node value made the walker visit only the first occurrence document-wide — deduping by location visits every occurrence exactly once (while a $ref-shared extension still counts once), which the stats need to count extensions correctly.
| export function ensureSpecExtensionDispatch(types: Record<string, NormalizedNodeType>) { | ||
| for (const type of Object.values(types)) { | ||
| if (type === SpecExtension) continue; | ||
| type.extensionsPrefix ??= EXTENSION_PREFIX; |
There was a problem hiding this comment.
Not sure this is still needed after using the SpecExtension visitor.
There was a problem hiding this comment.
It is needed: the walker only dispatches an x- key to SpecExtension when the type declares extensionsPrefix, and most AsyncAPI types (everything except SecurityScheme) plus Paths in OAS never declare it. Removing this line drops the AsyncAPI fixture's extension count — only the additionalProperties: {} path survives.
Adding extensionsPrefix to the AsyncAPI type definitions themselves would make extension values walked (and their $refs bundled/linted) for every consumer, which is a behavior change beyond stats — could be a follow-up if we want it.
| }, | ||
| }, | ||
| WebhooksMap: { | ||
| enter(node: unknown, ctx: UserContext) { |
There was a problem hiding this comment.
We also have other known extensions, like the following:
'x-servers': 'XServerList',
'x-tagGroups': 'TagGroups',
'x-ignoredHeaderParameters': { type: 'array', items: { type: 'string' } },Have you covered them?
There was a problem hiding this comment.
Yes, they are covered. Added e2e tests to make it clear
| } | ||
| } | ||
| const extensionNames = Object.keys(extensions).sort(); | ||
| statsAccumulator.xExtensions.total = extensionNames.length; |
There was a problem hiding this comment.
Why not use the same approach with Set like in other rows?
There was a problem hiding this comment.
Moreover, maybe we can simply calculate the items' size in place instead of assigning the total?
There was a problem hiding this comment.
On Set question already answered above.
Four consumers (three printers and the portal collector) read .total uniformly for every row, so deriving the size at read time would push an items ? items.size : total check into each of them. This one-time assignment at Root.leave also predates the PR — the loop just replaces the four per-row copies that main already had.
| const entryType = type.additionalProperties; | ||
| // An untyped catch-all (`additionalProperties: {}`) swallows x- keys before the extensions fallback. | ||
| if (isPlainObject(entryType) && !isNamedType(entryType) && entryType.type === undefined) { | ||
| type.additionalProperties = (_value, key: string) => |
There was a problem hiding this comment.
I don't think it's a good idea to modify types. What are you're trying to achieve?
There was a problem hiding this comment.
This modifies only the stats command's own copy of the types — lint keeps the original ones where the declarations matter for validation. Without it, typed extensions (x-codeSamples, x-logo) and most AsyncAPI nodes never reach the SpecExtension visitor, since the walker dispatches them to their declared types instead. The only alternative is going back to key-scanning in the any hook — the approach you wanted to replace with SpecExtension
| '@redocly/cli': patch | ||
| --- | ||
|
|
||
| Fixed the `stats` command always reporting `Parameters: 0` for AsyncAPI 2.x and 3.x descriptions. |
There was a problem hiding this comment.
| Fixed the `stats` command always reporting `Parameters: 0` for AsyncAPI 2.x and 3.x descriptions. | |
| Fixed the `stats` command reporting wrong parameter count for AsyncAPI descriptions. |
|
|
||
| currentLocation = resolvedLocation; | ||
| const isNodeSeen = seenNodesPerType[type.name]?.has?.(resolvedNode); | ||
| const seenKey = type === SpecExtension ? location.absolutePointer : resolvedNode; |
There was a problem hiding this comment.
@n0rahh
why scope the fix to SpecExtension only? can other primitive nodes hit the same problem?
If they can, using resolvedLocation.absolutePointer as a key instead of checking type === SpecExtension would cover them all in one place
There was a problem hiding this comment.
Only SpecExtension holds arbitrary user data, so equal scalar values like true are its normal case — other node types hold objects, which never collide as keys. Using the location as the key for all types isn't safe either: a YAML anchor puts the same object in many places, and every rule would then visit and report it once per place instead of once
…ions and updating counts

What/Why/How?
Adds a Vendor Extensions metric to the
statscommand. It reports how many distinctx-extensions a document uses and how many times each one occurs, shown in thestylish,json, andmarkdownoutput. Works across OpenAPI and AsyncAPI.The stats visitors collect extensions through a single
SpecExtensionentrypoint. Two walker fixes make that possible:SpecExtensionnodes now dedupe by location, so every occurrence is visited — previously occurrences with equal scalar values (e.g.x-internal: trueon many operations) were visited only once. This also means visitors and configurable rules targetingSpecExtensionnow fire per occurrence.x-properties with a declared type (e.g.x-codeSamples) were walked twice, which inflated other metrics.For the stats walk only,
ensureSpecExtensionDispatchadjusts the command's normalized types so everyx-key dispatches asSpecExtension— including natively-typed extensions and AsyncAPI types that don't declareextensionsPrefix. The structural extensionsx-webhooksandx-querykeep their declared types so the webhooks/operations/tags metrics still traverse their subtrees; the visitors count those two explicitly. Lint and bundle behavior is unchanged.The collector also gathers per-extension prop names and value samples for the portal's stats collector (telemetry) via the accumulator — the CLI prints only totals and counts. Samples are bounded (20 props / 20 values per extension), long strings become a
<string:N>marker, and credential-like keys and values are masked.Fixed the
statscommand always reportingParameters: 0for AsyncAPI 2.x and 3.x descriptions. Channel parameters are keyed by name rather than carrying anameproperty, so none of them were counted.Reference
Testing
Covered with unit and e2e tests.
Published snapshot and tested
clicommands in terminal.Screenshots (optional)
Check yourself
Security
Note
Medium Risk
Walker changes affect how often
SpecExtensionvisitors and rules fire per occurrence, though dispatch tweaks are scoped to the stats command; stats metrics and AsyncAPI parameter totals will change for existing AsyncAPI fixtures.Overview
Adds a Vendor Extensions metric to
statsfor OpenAPI and AsyncAPI: total distinctx-keys plus per-extension occurrence counts in stylish, JSON (counts), and markdown (breakdown table) output.Stats collection routes
x-keys through a unifiedSpecExtensionvisitor afterensureSpecExtensionDispatchadjusts normalized types for the stats walk only (lint/bundle unchanged).x-webhooksandx-querystay structurally typed and are counted explicitly. The walker now dedupesSpecExtensionvisits by document location so repeated scalars (e.g. manyx-internal: true) are counted correctly, and skips duplicatex-props already declared on the type tree.AsyncAPI Parameters now use the channel parameter map key instead of
parameter.name, fixing a constant0count on 2.x/3.x.Extension sampling for telemetry (masked/bounded in the accumulator) is collected in core; CLI output shows counts only.
Reviewed by Cursor Bugbot for commit 66b983c. Bugbot is set up for automated code reviews on this repo. Configure here.