diff --git a/.github/workflows/watch-mb-cloudevent.yml b/.github/workflows/watch-mb-cloudevent.yml new file mode 100644 index 0000000000..6d7a0258e4 --- /dev/null +++ b/.github/workflows/watch-mb-cloudevent.yml @@ -0,0 +1,119 @@ +name: Watch M&B cloudevent fields + +# Detects drift between the Metering & Billing plugin's emitted CloudEvent fields +# (declared as LuaLS ---@class/---@field annotations in kong-ee cloudevent.lua) and +# the docs. On change, opens a PR bumping the committed catalog snapshot and pings +# the docs maintainers to review the event-dimensions reference. +# +# DRAFT — depends on the upstream kong-ee annotations PR landing first. + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + # Optional: kong-ee could fire this on a change to cloudevent.lua for near-instant alerts. + repository_dispatch: + types: [MB_CLOUDEVENT_UPDATED] + +permissions: + contents: read + +env: + # Pin a real lua-language-server release; bump deliberately. + LUALS_VERSION: "3.13.6" + KONG_EE_REF: "master" + PLUGIN_DIR: "kong/plugins/metering-and-billing" + +jobs: + watch-mb-cloudevent: + name: Watch M&B cloudevent fields + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Harden Runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Create GitHub App Token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 + id: app-token + with: + app-id: ${{ vars.GH_APP_KONG_DOCS_ID }} + private-key: ${{ secrets.GH_APP_KONG_DOCS_SECRET }} + owner: Kong + + - name: Checkout developer.konghq.com + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: tools/mb-event-dimensions/package-lock.json + + - name: Install mb-event-dimensions dependencies + run: | + cd tools/mb-event-dimensions + npm ci + + - name: Checkout kong-ee repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: 'Kong/kong-ee' + token: ${{ steps.app-token.outputs.token }} + path: 'kong-ee' + ref: ${{ env.KONG_EE_REF }} + + - name: Install lua-language-server + run: | + curl -fsSL "https://github.com/LuaLS/lua-language-server/releases/download/${LUALS_VERSION}/lua-language-server-${LUALS_VERSION}-linux-x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin --wildcards 'bin/lua-language-server' --strip-components=1 + lua-language-server --version + + - name: Export cloudevent annotations to doc.json + run: | + mkdir -p luals-ws luals-out + cp "kong-ee/${PLUGIN_DIR}/cloudevent.lua" luals-ws/cloudevent.lua + printf '%s\n' '{ "runtime.version": "LuaJIT", "diagnostics.globals": ["kong","ngx"] }' > luals-ws/.luarc.json + lua-language-server --doc luals-ws --doc_out_path luals-out + + - name: Check for drift (vs snapshot and rendered data file) + id: drift + run: | + cd tools/mb-event-dimensions + if node check-drift.js ../../luals-out/doc.json; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + # regenerate the committed snapshot so the PR diff shows what moved + node extract-catalog.js ../../luals-out/doc.json --out event-fields.snapshot.json + fi + + - name: Create pull request + if: steps.drift.outputs.changed == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8 + with: + token: ${{ steps.app-token.outputs.token }} + branch: watch-mb-cloudevent + title: "Metering & Billing emitted event fields changed" + commit-message: "chore: sync M&B cloudevent field snapshot" + labels: skip-changelog,review:general + add-paths: tools/mb-event-dimensions/event-fields.snapshot.json + body: | + The Metering & Billing plugin's emitted CloudEvent fields changed upstream + (kong-ee `cloudevent.lua`). The committed field snapshot has been updated to match. + + **Action:** review `app/_kong_plugins/metering-and-billing/index.md` + (Captured event dimensions) and `app/_data/plugins/metering-and-billing.yaml`, + and update them for the added/removed fields shown in the CI log. + + - name: Notify Slack + if: ${{ steps.drift.outputs.changed == 'true' && !cancelled() }} + uses: ./.github/reusable-steps/notify-slack + with: + webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} + status: ${{ job.status }} diff --git a/tools/mb-event-dimensions/README.md b/tools/mb-event-dimensions/README.md new file mode 100644 index 0000000000..2980b9c11a --- /dev/null +++ b/tools/mb-event-dimensions/README.md @@ -0,0 +1,87 @@ +# mb-event-dimensions + +Extracts the Metering & Billing plugin's emitted CloudEvent field catalog from the +LuaLS annotations in kong-ee `cloudevent.lua`, and detects drift between those fields +and the docs (the `Captured event dimensions` reference on the plugin page). + +The fields are declared as `---@class` / `---@field` annotations in +`kong/plugins/metering-and-billing/cloudevent.lua`; `lua-language-server --doc` exports +them to a `doc.json`, which these scripts consume. + +## How it works + +Requires `Kong/kong-ee` available locally and `lua-language-server` on `PATH`. From the +root of your clone of the dev site repo: + +```bash +cd tools/mb-event-dimensions +npm ci +``` + +Export the annotations to `doc.json` (point `` at your kong-ee clone): + +```bash +mkdir -p /tmp/mb-ws /tmp/mb-out +cp /kong/plugins/metering-and-billing/cloudevent.lua /tmp/mb-ws/ +echo '{ "runtime.version": "LuaJIT", "diagnostics.globals": ["kong","ngx"] }' > /tmp/mb-ws/.luarc.json +lua-language-server --doc /tmp/mb-ws --doc_out_path /tmp/mb-out +``` + +## How to run it + +Extract the catalog (`{ "kong.api_request": [...], "kong.llm_request": [...] }`): + +```bash +node extract-catalog.js /tmp/mb-out/doc.json # prints JSON +node extract-catalog.js /tmp/mb-out/doc.json --out event-fields.snapshot.json +``` + +Check for drift against the committed snapshot and the rendered data file +(`app/_data/plugins/metering-and-billing.yaml`): + +```bash +node check-drift.js /tmp/mb-out/doc.json +``` + +Exit `0` when everything agrees; exit `1` with a printed diff (added/removed fields) when +the plugin's emitted fields no longer match the snapshot or the docs. + +`event-fields.snapshot.json` is the committed baseline. The scheduled workflow +`.github/workflows/watch-mb-cloudevent.yml` runs this check daily; on drift it bumps the +snapshot, opens a PR, and pings the docs maintainers to update the reference. + +## Example drift output + +New field added (`cache_region`): + +``` +DRIFT vs snapshot [kong.api_request]: + + cache_region: string? (new in cloudevent.lua) +DOC MISMATCH [kong.api_request] (data file vs cloudevent.lua): + emitted but undocumented: cache_region + +2 problem(s) found — the docs reference needs updating. +``` + +Field removed (`upstream_status`): + +``` +DRIFT vs snapshot [kong.api_request]: + - upstream_status: integer? (gone from cloudevent.lua) +DOC MISMATCH [kong.api_request] (data file vs cloudevent.lua): + data-file only (not emitted): upstream_status + +2 problem(s) found — the docs reference needs updating. +``` + +Field type changed (`service_port` `integer?` → `string?`): + +``` +DRIFT vs snapshot [kong.api_request]: + ~ service_port: integer? → string? (type changed) + +1 problem(s) found — the docs reference needs updating. +``` + +Add/remove trip both checks (the field-name set changes); a pure type change trips only the +snapshot check (the data-file cross-check is name-only). All exit `1`. diff --git a/tools/mb-event-dimensions/check-drift.js b/tools/mb-event-dimensions/check-drift.js new file mode 100644 index 0000000000..5113c2ca38 --- /dev/null +++ b/tools/mb-event-dimensions/check-drift.js @@ -0,0 +1,79 @@ +// Detect drift in the Metering & Billing emitted-event fields. +// +// node check-drift.js +// +// Compares the field catalog freshly extracted from the kong-ee LuaLS export +// against two things: +// 1. the committed baseline snapshot (event-fields.snapshot.json) — the +// "known good" the docs were last reconciled with; drift here is what the +// scheduled workflow turns into an alert PR. +// 2. the rendered reference data file (app/_data/plugins/metering-and-billing.yaml) +// — so the published tables can't silently disagree with the plugin. +// +// Exits non-zero (with a printed diff) on any drift; exit 0 when everything agrees. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; +import { extractCatalog } from './extract-catalog.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SNAPSHOT = path.join(HERE, 'event-fields.snapshot.json'); +const DATA_FILE = path.resolve(HERE, '../../app/_data/plugins/metering-and-billing.yaml'); + +const nameSet = (fields) => new Set(fields.map((f) => f.name)); +const typeMap = (fields) => new Map(fields.map((f) => [f.name, f.type])); +const diff = (a, b) => ({ added: [...b].filter((x) => !a.has(x)), removed: [...a].filter((x) => !b.has(x)) }); + +const docPath = process.argv[2]; + +if (!docPath) { + console.error('usage: node check-drift.js '); + process.exit(2); +} + +const current = extractCatalog(docPath); +let problems = 0; + +// 1) drift vs committed snapshot (added / removed / type-changed, keyed by field name) +const snapshot = JSON.parse(fs.readFileSync(SNAPSHOT, 'utf8')); + +for (const event of Object.keys(current)) { + const cur = typeMap(current[event]); + const snap = typeMap(snapshot[event] || []); + const added = [...cur.keys()].filter((n) => !snap.has(n)); + const removed = [...snap.keys()].filter((n) => !cur.has(n)); + const changed = [...cur.keys()].filter((n) => snap.has(n) && snap.get(n) !== cur.get(n)); + + if (added.length || removed.length || changed.length) { + problems++; + console.error(`DRIFT vs snapshot [${event}]:`); + added.forEach((n) => console.error(` + ${n}: ${cur.get(n)} (new in cloudevent.lua)`)); + removed.forEach((n) => console.error(` - ${n}: ${snap.get(n)} (gone from cloudevent.lua)`)); + changed.forEach((n) => console.error(` ~ ${n}: ${snap.get(n)} → ${cur.get(n)} (type changed)`)); + } +} + +// 2) rendered data file must match the code catalog (field names) +const data = yaml.load(fs.readFileSync(DATA_FILE, 'utf8')); +const portal = (data.portal_fields || []).map((f) => f.field); + +for (const ev of data.events || []) { + const shipped = new Set([...(ev.fields || []).map((f) => f.field), ...portal]); + const code = nameSet(current[ev.type] || []); + const { added, removed } = diff(code, shipped); + + if (added.length || removed.length) { + problems++; + console.error(`DOC MISMATCH [${ev.type}] (data file vs cloudevent.lua):`); + added.forEach((x) => console.error(` data-file only (not emitted): ${x}`)); + removed.forEach((x) => console.error(` emitted but undocumented: ${x}`)); + } +} + +if (problems) { + console.error(`\n${problems} problem(s) found — the docs reference needs updating.`); + process.exit(1); +} + +console.log('OK: extracted catalog matches the snapshot and the rendered data file.'); diff --git a/tools/mb-event-dimensions/event-fields.snapshot.json b/tools/mb-event-dimensions/event-fields.snapshot.json new file mode 100644 index 0000000000..f1958cc164 --- /dev/null +++ b/tools/mb-event-dimensions/event-fields.snapshot.json @@ -0,0 +1,166 @@ +{ + "kong.api_request": [ + { + "name": "api_id", + "type": "string?" + }, + { + "name": "api_package_id", + "type": "string?" + }, + { + "name": "api_product_version_id", + "type": "string?" + }, + { + "name": "application_id", + "type": "string?" + }, + { + "name": "client_ip", + "type": "string?" + }, + { + "name": "control_plane_id", + "type": "string?" + }, + { + "name": "portal_id", + "type": "string?" + }, + { + "name": "request_host", + "type": "string?" + }, + { + "name": "request_method", + "type": "string?" + }, + { + "name": "request_size_bytes", + "type": "integer?" + }, + { + "name": "request_uri", + "type": "string?" + }, + { + "name": "request_user_agent", + "type": "string?" + }, + { + "name": "response_http_status", + "type": "integer?" + }, + { + "name": "response_size_bytes", + "type": "integer?" + }, + { + "name": "route_id", + "type": "string?" + }, + { + "name": "route_name", + "type": "string?" + }, + { + "name": "service_id", + "type": "string?" + }, + { + "name": "service_name", + "type": "string?" + }, + { + "name": "service_port", + "type": "integer?" + }, + { + "name": "service_protocol", + "type": "string?" + }, + { + "name": "subject_type", + "type": "string?" + }, + { + "name": "upstream_status", + "type": "integer?" + } + ], + "kong.llm_request": [ + { + "name": "ai_plugin_id", + "type": "string?" + }, + { + "name": "ai_plugin_name", + "type": "string?" + }, + { + "name": "api_id", + "type": "string?" + }, + { + "name": "api_package_id", + "type": "string?" + }, + { + "name": "api_product_version_id", + "type": "string?" + }, + { + "name": "api_request_id", + "type": "string?" + }, + { + "name": "application_id", + "type": "string?" + }, + { + "name": "cache_status", + "type": "string?" + }, + { + "name": "control_plane_id", + "type": "string?" + }, + { + "name": "http_status", + "type": "integer?" + }, + { + "name": "model", + "type": "string?" + }, + { + "name": "portal_id", + "type": "string?" + }, + { + "name": "provider", + "type": "string?" + }, + { + "name": "route_id", + "type": "string?" + }, + { + "name": "service_id", + "type": "string?" + }, + { + "name": "subject_type", + "type": "string?" + }, + { + "name": "tokens", + "type": "integer?" + }, + { + "name": "type", + "type": "string?" + } + ] +} diff --git a/tools/mb-event-dimensions/extract-catalog.js b/tools/mb-event-dimensions/extract-catalog.js new file mode 100644 index 0000000000..7cc43e08d4 --- /dev/null +++ b/tools/mb-event-dimensions/extract-catalog.js @@ -0,0 +1,68 @@ +// Extract the Metering & Billing emitted-event field catalog from the LuaLS +// `--doc` JSON export (doc.json) of the kong-ee cloudevent.lua annotations. +// +// lua-language-server --doc --doc_out_path # produces /doc.json +// node extract-catalog.js /doc.json [--out catalog.json] +// +// doc.json is an array of type definitions; each of our event classes carries a +// `.fields` array with inheritance already resolved (portal fields folded in). +import fs from 'node:fs'; + +// LuaLS class name -> emitted CloudEvent type. +export const CLASS_TO_EVENT = { + 'kong.mnb.ApiRequestData': 'kong.api_request', + 'kong.mnb.LlmRequestData': 'kong.llm_request', +}; + +// Fail-loud floor: if the parser finds fewer than this, the annotations were +// probably renamed/removed or the export shape changed — better to error than +// emit a wrong catalog that would silently pass the drift check. +const MIN_FIELDS = 10; + +export function extractCatalog(docJsonPath) { + const doc = JSON.parse(fs.readFileSync(docJsonPath, 'utf8')); + + if (!Array.isArray(doc)) throw new Error(`${docJsonPath}: expected a JSON array from lua-language-server --doc`); + + const byName = new Map(doc.map((x) => [x.name, x])); + const catalog = {}; + + for (const [cls, event] of Object.entries(CLASS_TO_EVENT)) { + const entry = byName.get(cls); + + if (!entry) throw new Error(`doc.json: class '${cls}' not found — cloudevent.lua annotations missing or renamed?`); + + const fields = (entry.fields || []) + .map((f) => ({ name: f.name, type: (f.extends && f.extends.view) || 'unknown' })) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (fields.length < MIN_FIELDS) { + throw new Error(`doc.json: class '${cls}' yielded only ${fields.length} fields (expected >= ${MIN_FIELDS})`); + } + + catalog[event] = fields; + } + + return catalog; +} + +// CLI +if (import.meta.url === `file://${process.argv[1]}`) { + const docPath = process.argv[2]; + + if (!docPath) { + console.error('usage: node extract-catalog.js [--out ]'); + process.exit(2); + } + + const catalog = extractCatalog(docPath); + const json = JSON.stringify(catalog, null, 2) + '\n'; + const outIdx = process.argv.indexOf('--out'); + + if (outIdx !== -1 && process.argv[outIdx + 1]) { + fs.writeFileSync(process.argv[outIdx + 1], json); + console.error(`wrote ${process.argv[outIdx + 1]}`); + } else { + process.stdout.write(json); + } +} diff --git a/tools/mb-event-dimensions/package-lock.json b/tools/mb-event-dimensions/package-lock.json new file mode 100644 index 0000000000..377cbfe071 --- /dev/null +++ b/tools/mb-event-dimensions/package-lock.json @@ -0,0 +1,44 @@ +{ + "name": "mb-event-dimensions", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mb-event-dimensions", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "js-yaml": "^4.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + } + } +} diff --git a/tools/mb-event-dimensions/package.json b/tools/mb-event-dimensions/package.json new file mode 100644 index 0000000000..5a9d19d880 --- /dev/null +++ b/tools/mb-event-dimensions/package.json @@ -0,0 +1,14 @@ +{ + "name": "mb-event-dimensions", + "version": "1.0.0", + "description": "Extract the Metering & Billing plugin's emitted-event field catalog from the kong-ee LuaLS doc.json export, and detect drift against the docs.", + "type": "module", + "scripts": { + "extract": "node extract-catalog.js", + "check": "node check-drift.js" + }, + "license": "Apache-2.0", + "dependencies": { + "js-yaml": "^4.1.0" + } +}