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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/map-telemetry-kinds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@evlog/cli': patch
---

`evlog map` telemetry now records per-kind entry point totals and dark counts (`mapKindPage`, `mapDarkPage`, ...), and the same split by sensitivity (`mapSensitiveMoney`, `mapDarkMoney`, ...). A kind absent from the project is omitted rather than sent as zero. The disclosure table from `evlog telemetry status` and the CLI telemetry docs were updated to match.
4 changes: 4 additions & 0 deletions apps/docs/content/3.cli/7.telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ Every string is an id from the CLI's own catalog and is checked against an allow
| --- | --- |
| Score and grade | `mapScore: 72`, `mapGrade: good` |
| Entry point counts by coverage | `mapEntryPoints: 34`, `mapDark: 9` |
| Per kind: entry points and dark entry points | `mapKindPage: 12`, `mapDarkPage: 3` |
| Per sensitivity: money / auth / PII entry points, and dark | `mapSensitiveMoney: 2`, `mapDarkMoney: 1` |
| Per rule: how many entry points failed it, how many waived it | `mapFailWideEvent: 6`, `mapSuppressedWideEvent: 2` |
| Which gate ran, and whether it failed | `mapGate: baseline`, `mapGateFailed: true` |

Rule ids are the CLI's own closed set and are already public. Everything read out of your source is a count: **no route path, no file name, no project name, no snippet**.

Kinds are the CLI's own closed set too, and a kind absent from the project is omitted rather than sent as zero, the same convention as a flag left at its default. The dark count for a kind that is present but fully covered is `0`, so `mapKindPage: 12, mapDarkPage: 3` always reads as "12 pages, 3 dark", never as a missing number. Sensitivity is a heuristic classification (imports and path terms, see the report's `$` / `A` / `o` markers), so the per-sensitivity counts are an estimate of what the classifier found, not ground truth.

## See what is collected

```bash [Terminal]
Expand Down
18 changes: 18 additions & 0 deletions apps/telemetry/server/utils/allowed-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,28 @@ export const DEFAULT_ALLOWED_CUSTOM_KEYS: Record<string, string[]> = {
'mapWrote',
'mapEntryPoints',
'mapSensitive',
'mapSensitiveMoney',
'mapSensitiveAuth',
'mapSensitivePii',
'mapInstrumented',
'mapPartial',
'mapDark',
'mapDarkApi',
'mapDarkPage',
'mapDarkMiddleware',
'mapDarkServerAction',
'mapDarkCron',
'mapDarkWebsocket',
'mapDarkMoney',
'mapDarkAuth',
'mapDarkPii',
'mapExempt',
'mapKindApi',
'mapKindPage',
'mapKindMiddleware',
'mapKindServerAction',
'mapKindCron',
'mapKindWebsocket',
'mapSuppressedChecks',
'mapWarnings',
'mapSuggestions',
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/map/sensitivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function sensitivityBadge(sensitivity: Sensitivity): string {
const BADGES: Record<string, string> = { money: '$', auth: 'A', pii: 'o' }

/** What makes this entry point sensitive — `money`, `auth`, `pii`, or nothing. */
export function sensitivityLabel(sensitivity: Sensitivity): string {
export function sensitivityLabel(sensitivity: Sensitivity): 'money' | 'auth' | 'pii' | '' {
if (sensitivity.reasons.some(reason => reason.startsWith('money:'))) return 'money'
if (sensitivity.reasons.some(reason => reason.startsWith('auth:'))) return 'auth'
if (sensitivity.level === 'medium') return 'pii'
Expand Down
99 changes: 94 additions & 5 deletions packages/cli/src/lib/map/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { telemetry } from '@evlog/telemetry'
import type { BaselineComparison } from './baseline'
import { hasRegressed } from './baseline'
import { RULES } from './rules/index'
import type { CheckId, Framework, Grade, ScanResult } from './types'
import { classifyRouteObservability } from './score'
import { sensitivityLabel } from './sensitivity'
import type { CheckId, Framework, Grade, RouteEntry, RouteKind, ScanResult } from './types'

/**
* What `evlog map` reports about a scan.
Expand All @@ -12,15 +14,23 @@ import type { CheckId, Framework, Grade, ScanResult } from './types'
* can travel as values; everything read out of the user's source stays a count.
*
* The distribution these fields produce is what calibrates the tool itself: the
* 90/70/50 grade bands are a guess until real scores land against them, and a
* rule suppressed on most of the entry points it fires on is a bad rule rather
* than bad code.
* 90/70/50 grade bands are a guess until real scores land against them, a rule
* suppressed on most of the entry points it fires on is a bad rule rather than
* bad code, and the kind split says whether the next rule should be about
* scheduled jobs or server actions.
*/
const PREFIX = 'map'

const FRAMEWORKS: readonly Framework[] = ['nuxt', 'nitro', 'next', 'tanstack-start']
const GRADES: readonly Grade[] = ['excellent', 'good', 'needs-work', 'at-risk']

/** Entry-point kinds the map can scan, a closed set, in scan order. */
const KINDS: readonly RouteKind[] = ['api', 'page', 'middleware', 'server-action', 'cron', 'websocket']

/** Sensitivity labels the classifier can assign, in precedence order. */
const SENSITIVITIES = ['money', 'auth', 'pii'] as const
type SensitivityLabel = typeof SENSITIVITIES[number]

/** Which gate the run asked for — `--min-score`, `--baseline`, both, neither. */
const GATES = ['none', 'min-score', 'baseline', 'both'] as const
export type MapGate = typeof GATES[number]
Expand All @@ -47,6 +57,16 @@ export function ruleField(group: 'Fail' | 'Suppressed', id: CheckId): string {
return `${PREFIX}${group}${pascal(id)}`
}

/** Field name for a per-kind tally: `server-action` → `mapKindServerAction`. */
export function kindField(group: 'Kind' | 'Dark', kind: RouteKind): string {
return `${PREFIX}${group}${pascal(kind)}`
}

/** Field name for a per-sensitivity tally: `money` → `mapSensitiveMoney`. */
export function sensitiveField(group: 'Sensitive' | 'Dark', label: SensitivityLabel): string {
return `${PREFIX}${group}${pascal(label)}`
}

/** What the run was asked to gate on, from the two flags that can gate it. */
export function resolveGate(input: { minScore: boolean, baseline: boolean }): MapGate {
if (input.minScore && input.baseline) return 'both'
Expand All @@ -73,6 +93,61 @@ function ruleTallies(scan: ScanResult): Record<string, number> {
return out
}

/**
* Per-kind totals and dark tallies across every scanned entry point.
*
* A kind absent from the project is omitted rather than sent as zero, the
* same convention as flags left at their default. A kind that is present but
* fully covered still reports its dark count as 0, so the pair reads as
* "12 pages, 3 dark" and never as "12 pages, count missing".
*/
function kindTallies(routes: RouteEntry[]): Record<string, number> {
const total: Partial<Record<RouteKind, number>> = {}
const dark: Partial<Record<RouteKind, number>> = {}
for (const route of routes) {
total[route.kind] = (total[route.kind] ?? 0) + 1
if (classifyRouteObservability(route) === 'dark') dark[route.kind] = (dark[route.kind] ?? 0) + 1
}

const out: Record<string, number> = {}
for (const kind of KINDS) {
const count = total[kind]
if (count === undefined) continue
out[kindField('Kind', kind)] = count
out[kindField('Dark', kind)] = dark[kind] ?? 0
}
return out
}

/**
* Per-sensitivity totals and dark tallies (money / auth / pii).
*
* Sensitivity is a heuristic classification, not ground truth: these counts
* say what the classifier found, so a population-level "dark money handlers"
* number has to be read as an estimate. Labels are mutually exclusive per
* entry point (`sensitivityLabel` precedence), so the total and dark buckets
* stay disjoint.
*/
function sensitiveTallies(routes: RouteEntry[]): Record<string, number> {
const total: Partial<Record<SensitivityLabel, number>> = {}
const dark: Partial<Record<SensitivityLabel, number>> = {}
for (const route of routes) {
const label = sensitivityLabel(route.sensitivity)
if (!label) continue
total[label] = (total[label] ?? 0) + 1
if (classifyRouteObservability(route) === 'dark') dark[label] = (dark[label] ?? 0) + 1
}

const out: Record<string, number> = {}
for (const label of SENSITIVITIES) {
const count = total[label]
if (count === undefined) continue
out[sensitiveField('Sensitive', label)] = count
out[sensitiveField('Dark', label)] = dark[label] ?? 0
}
return out
}

/** Every field {@link recordMapRun} can emit — the payload shape, in one place. */
export function mapTelemetryFields(input: {
scan: ScanResult
Expand Down Expand Up @@ -104,6 +179,8 @@ export function mapTelemetryFields(input: {
mapSuggestions: routes.reduce((total, route) => total + Object.keys(route.suggestions).length, 0),
mapProjectSuggestions: scan.suggestions.length,
mapGate: input.gate,
...kindTallies(routes),
...sensitiveTallies(routes),
...ruleTallies(scan),
}

Expand Down Expand Up @@ -138,6 +215,11 @@ export function recordMapRun(input: Parameters<typeof mapTelemetryFields>[0]): v
* Read off a synthetic payload rather than listed again: a field added to one
* and not the other would leave the disclosure quietly incomplete, and what
* this CLI transmits is exactly the thing that must not drift.
*
* The kind and sensitivity tallies are the one deliberate exception: absent
* kinds are omitted from the payload, so the empty synthetic scan cannot name
* them, and the disclosure has to list every field the module *can* emit
* rather than every field an empty project would.
*/
export function mapTelemetryFieldNames(): string[] {
const empty: ScanResult = {
Expand All @@ -161,7 +243,7 @@ export function mapTelemetryFieldNames(): string[] {
removed: [],
}

return Object.keys(mapTelemetryFields({
const payload = Object.keys(mapTelemetryFields({
scan: empty,
frameworkForced: false,
gate: 'none',
Expand All @@ -170,4 +252,11 @@ export function mapTelemetryFieldNames(): string[] {
view: 'summary',
wrote: false,
}))

const tallies = [
...KINDS.flatMap(kind => [kindField('Kind', kind), kindField('Dark', kind)]),
...SENSITIVITIES.flatMap(label => [sensitiveField('Sensitive', label), sensitiveField('Dark', label)]),
]

return [...payload, ...tallies]
}
114 changes: 113 additions & 1 deletion packages/cli/test/map/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import type { BaselineComparison } from '../../src/lib/map/baseline'
import { RULES } from '../../src/lib/map/rules/index'
import {
MAP_TELEMETRY_FIELDS,
kindField,
mapTelemetryFieldNames,
mapTelemetryFields,
resolveGate,
ruleField,
sensitiveField,
} from '../../src/lib/map/telemetry'
import type { RouteEntry, ScanResult } from '../../src/lib/map/types'
import type { RouteEntry, RouteKind, ScanResult } from '../../src/lib/map/types'

function route(overrides: Partial<RouteEntry> = {}): RouteEntry {
return {
Expand Down Expand Up @@ -144,4 +146,114 @@ describe('map telemetry', () => {
expect(MAP_TELEMETRY_FIELDS.mapFramework).toContain('tanstack-start')
expect(MAP_TELEMETRY_FIELDS.mapGrade).toContain('at-risk')
})

it('counts entry points and dark entry points per kind', () => {
const out = fields({
scan: scan({
map: {
version: 1,
generatedAt: '',
framework: 'nuxt',
projectName: 'shop',
score: 50,
routes: [
route({ id: 'a', kind: 'page', checks: { 'page-error-handling': { status: 'fail' } } }),
route({ id: 'b', kind: 'page', checks: { 'page-error-handling': { status: 'pass' } } }),
route({ id: 'c', kind: 'api', checks: {} }),
route({ id: 'd', kind: 'cron', checks: {} }),
],
},
}),
})

expect(out.mapKindPage).toBe(2)
expect(out.mapDarkPage).toBe(1)
expect(out.mapKindApi).toBe(1)
expect(out.mapDarkApi).toBe(1)
expect(out.mapKindCron).toBe(1)
expect(out.mapDarkCron).toBe(1)
})

it('omits kinds absent from the project instead of sending zero', () => {
const out = fields({
scan: scan({
map: {
version: 1,
generatedAt: '',
framework: 'nuxt',
projectName: 'shop',
score: 50,
routes: [route({ id: 'a', kind: 'cron' })],
},
}),
})

expect(out.mapKindCron).toBe(1)
expect(out.mapDarkCron).toBe(0)
expect('mapKindPage' in out).toBe(false)
expect('mapDarkPage' in out).toBe(false)
expect('mapKindWebsocket' in out).toBe(false)
})

it('reports a dark count of zero for a kind that is fully covered', () => {
const out = fields({
scan: scan({
map: {
version: 1,
generatedAt: '',
framework: 'nuxt',
projectName: 'shop',
score: 100,
routes: [route({ id: 'a', checks: { 'wide-event': { status: 'pass' }, 'context': { status: 'pass' } } })],
},
}),
})

expect(out.mapKindApi).toBe(1)
expect(out.mapDarkApi).toBe(0)
})

it('counts sensitive entry points and dark sensitive entry points by label', () => {
const out = fields({
scan: scan({
map: {
version: 1,
generatedAt: '',
framework: 'nuxt',
projectName: 'shop',
score: 50,
routes: [
route({ id: 'a', checks: {}, sensitivity: { level: 'high', reasons: ['money: imports stripe'] } }),
route({
id: 'b',
checks: { 'wide-event': { status: 'pass' }, 'context': { status: 'pass' } },
sensitivity: { level: 'high', reasons: ['auth: imports better-auth'] },
}),
route({ id: 'c', checks: {}, sensitivity: { level: 'medium', reasons: ['pii: write operation with sensitive fields'] } }),
],
},
}),
})

expect(out.mapSensitiveMoney).toBe(1)
expect(out.mapDarkMoney).toBe(1)
expect(out.mapSensitiveAuth).toBe(1)
expect(out.mapDarkAuth).toBe(0)
expect(out.mapSensitivePii).toBe(1)
expect(out.mapDarkPii).toBe(1)
expect('mapSensitiveNone' in out).toBe(false)
})

it('discloses a total and a dark field for every kind and sensitivity label', () => {
const names = mapTelemetryFieldNames()
const kinds: readonly RouteKind[] = ['api', 'page', 'middleware', 'server-action', 'cron', 'websocket']
for (const kind of kinds) {
expect(names).toContain(kindField('Kind', kind))
expect(names).toContain(kindField('Dark', kind))
}
for (const label of ['money', 'auth', 'pii'] as const) {
expect(names).toContain(sensitiveField('Sensitive', label))
expect(names).toContain(sensitiveField('Dark', label))
}
})
})
Loading