@eventra_dev/eventra-cli
v2.0.9
Published
Static analytics event extraction CLI powered by the TypeScript compiler API
Maintainers
Keywords
Readme
Eventra CLI
Eventra CLI statically discovers analytics events from @eventra_dev/eventra-sdk usage in your codebase — including function wrappers and cross-file propagation chains.
Overview
The CLI scans TypeScript and JavaScript with the TypeScript compiler API and extracts only calls to Eventra.prototype.track() on instances of Eventra imported from @eventra_dev/eventra-sdk.
It does not detect generic track(), Segment, Google Analytics, or other libraries out of the box. Framework files (e.g. .vue, .svelte, .astro, Angular's .html templates) require an optional plugin.
Installation
npm install -D @eventra_dev/eventra-cli
# or
pnpm add -D @eventra_dev/eventra-cliYour project should use the runtime SDK:
pnpm add @eventra_dev/eventra-sdkQuick Start
eventra init
eventra sync
eventra check
eventra sendWhat gets detected
Direct SDK calls
import { Eventra } from "@eventra_dev/eventra-sdk";
const tracker = new Eventra({ apiKey: "YOUR_PROJECT_API_KEY" });
tracker.track("checkout.completed");
tracker.track("app.loaded", { userId: "user_123" });
tracker?.track("optional.chain");Function wrappers (propagation)
Wrappers that call Eventra.track() inside are registered automatically:
function trackFeature(name: string) {
sdk.track(name);
}
trackFeature("purchase");The event name can also come from a property on the wrapper's parameter — plain access, optional chaining, element access, or destructuring (including aliased and nested):
function trackWrapper(payload: { event: string }) {
sdk.track(payload.event); // also: payload?.event, payload["event"]
}
trackWrapper({ event: "checkout" });
function trackDestructured({ event: name }: { event: string }) {
sdk.track(name);
}
trackDestructured({ event: "checkout" });Variables, templates, conditionals
const EVENT = "signup";
tracker.track(EVENT);
tracker.track(`feature_${type}`);
tracker.track(flag ? "path.a" : "path.b");Cross-file
// tracker.ts
export function trackFeature(name: string) {
client.track(name);
}
// app.ts
import { trackFeature } from "./tracker";
trackFeature("purchase");Event name rules (aligned with SDK)
| Rule | Limit |
|------|--------|
| Max length | 64 characters (same as SDK) |
| Allowed characters | a-zA-Z0-9:_./- |
| Position | First argument of .track(name, options?) |
The second argument (userId, properties) is not used as the event name.
A literal name that violates either rule (too long, or a character outside the allowed set) is not truncated and not flagged as a dynamic occurrence - it's silently excluded from the scan result, with no warning and no diagnostic anywhere. It simply never appears in eventra.json after sync. If an event you expect to see is missing, check this before assuming a detection bug.
What is ignored
// Not from @eventra_dev/eventra-sdk
track("legacy");
analytics.track("ga");
segment.track("x");
// Wrong API shape for SDK (object as first argument)
tracker.track({ event: "click" });Commands
eventra sync
Full project scan → updates eventra.json:
events— discovered event namesfunctionWrappers— detected wrapper functions
eventra check
Compares config with the current scan (events + wrappers). Exit code 1 on drift.
eventra check --fix
Writes scan results into eventra.json.
eventra watch
Incremental scan with the same rules as sync.
eventra send
Uploads events from config to the Eventra API. Requires an API key and (optionally) an endpoint. Events are POSTed to POST /api/v1/cli/events and marked as non-billable on the backend.
API key resolution (checked in this order, never written to eventra.json):
EVENTRA_API_KEYenvironment variable — recommended for CIeventra.local.json(created byeventra init/eventra send, automatically gitignored)- legacy inline
apiKeyineventra.json(deprecated — avoid committing real keys here)
eventra.json is meant to be committed so CI can diff it via eventra check; keeping the key out of it avoids leaking it into source control. In a non-interactive shell (no TTY), send fails fast with a message pointing at EVENTRA_API_KEY instead of hanging on a prompt.
Endpoint trust. The default production endpoint is always used with no extra step. A custom endpoint is only trusted without approval when it comes from the EVENTRA_ENDPOINT environment variable (setting an env var is already a local/CI action). A custom endpoint committed inline in eventra.json is trust-on-first-use: send refuses to run until it's approved once with:
eventra send --trust-endpointThis records the endpoint in eventra.local.json (gitignored). If eventra.json's endpoint later changes to a different value — e.g. a PR silently pointing it at another host — send blocks again until re-approved, so a committed config change alone can't redirect where your API key and events get sent.
Once eventra.json already commits an endpoint, that committed value is what events actually get sent to - EVENTRA_ENDPOINT only controls whether the approval prompt is skipped; its own value is not read or compared against anything in that case. EVENTRA_ENDPOINT becomes the real destination only when eventra.json has no endpoint set at all. In other words, setting the variable to point somewhere else does not redirect traffic away from an already-committed endpoint - it only means you won't be asked to approve it.
Network resilience:
- Up to 4 attempts with exponential backoff + jitter (capped at 8 s)
- Retries on 429 and 5xx responses, and on transport errors (timeout / DNS / connection reset)
- Permanent failure (4xx other than 429) surfaces immediately without retry
- 10 s timeout per attempt via
AbortController
Plugins
The CLI core is framework-agnostic. Extensions are separate npm packages with their own types — the CLI loads them from eventra.json and adapts their output internally.
Vue (.vue SFC)
Install the official Vue plugin and enable it in config:
pnpm add -D @eventra_dev/cli-plugin-vue{
"plugins": ["@eventra_dev/cli-plugin-vue"],
"sync": {
"include": ["**/*.{ts,tsx,js,jsx}"],
"exclude": ["node_modules", "dist", ".next", ".git"]
}
}The plugin:
- parses each
.vuefile with the real Vue compiler (@vue/compiler-sfc) into a single virtual TypeScript module combining<script>/<script setup> - extracts
track()calls from the script exactly like a regular.tsfile (direct calls, wrappers, propagation) - detects
event="feature_name"attributes in<template>— both literal and dynamic (:event="expr"); dynamic bindings resolve through the same module scope as the script, or surface as a dynamic occurrence if unresolvable - Nuxt
.vuefiles (pages/layouts/components) are handled the same way; auto-imported composables resolve once the project has runnuxt prepare/nuxt dev
sync.include does not need **/*.vue manually — the plugin registers **/*.vue via includeGlobs. See @eventra_dev/cli-plugin-vue for details.
Svelte (.svelte)
pnpm add -D @eventra_dev/cli-plugin-svelte{
"plugins": ["@eventra_dev/cli-plugin-svelte"]
}The plugin:
- parses each
.sveltefile with the real Svelte compiler (svelte/compiler) into a single virtual TypeScript module combiningcontext="module"and instance<script>blocks - extracts
track()calls from the script exactly like a regular.tsfile (direct calls, wrappers, propagation); Svelte 5 runes ($props,$state, …) pass through untouched - detects
event="feature_name"attributes on any tag — plain elements, components,{#if}/{#each}/{#await}/{#key}blocks, slots — both literal and dynamic (event={expr}); dynamic bindings resolve through the same module scope as the script, or surface as a dynamic occurrence if unresolvable
sync.include does not need **/*.svelte manually. See @eventra_dev/cli-plugin-svelte for details.
Astro (.astro)
pnpm add -D @eventra_dev/cli-plugin-astro{
"plugins": ["@eventra_dev/cli-plugin-astro"]
}The plugin:
- parses each
.astrofile with the real Astro compiler (@astrojs/compiler) into a single virtual TypeScript module from the frontmatter - extracts
track()calls from the frontmatter exactly like a regular.tsfile (direct calls, wrappers, propagation);Astro.propsand other globals pass through untouched - detects
event="feature_name"attributes on any tag, including inside JSX expressions ({cond && <Button event="..." />},.map()) — both literal and dynamic (event={expr}, including the{event}shorthand); dynamic bindings resolve through the same module scope as the frontmatter, or surface as a dynamic occurrence if unresolvable
sync.include does not need **/*.astro manually. See @eventra_dev/cli-plugin-astro for details.
Angular (.html, via templateUrl)
pnpm add -D @eventra_dev/cli-plugin-angular{
"plugins": ["@eventra_dev/cli-plugin-angular"],
"sync": {
"include": ["**/*.{ts,tsx,js,jsx}"],
"exclude": ["node_modules", "dist", ".angular", ".git"]
}
}The plugin:
- parses each component template with the real Angular compiler (
@angular/compiler) into a virtual TypeScript module, paired with its component class (foo.component.html↔foo.component.ts, Angular CLI's default naming) - extracts
track()calls from the component class exactly like a regular.tsfile (direct calls, wrappers, propagation) - no plugin needed for that part - detects
event="feature_name"attributes on any tag, including*ngIf/*ngForand the@if/@for/@switchblock syntax - both literal and dynamic; a dynamic binding resolves against the component instance (this.field, including getters), or surfaces as a dynamic occurrence if unresolvable - use
[attr.event]="expr", not[event]="expr", for a dynamic binding on a plain HTML element -[event]is a property binding that Angular's real compiler rejects (NG8002) unless the element is a component that actually declares@Input() event; both forms are detected identically by the plugin, only[attr.event]is guaranteed to compile
sync.include does not need **/*.html manually. Only templateUrl-based components are covered - an inline template: "..." isn't yet. Requires Node ^20.19.0 || ^22.12.0 || >=24.0.0 (higher than the other plugins' >=18 floor - @angular/compiler's own requirement). See @eventra_dev/cli-plugin-angular for details.
Before publishing / local development
Plugins are resolved from your project's node_modules (same as any dependency):
{
"devDependencies": {
"@eventra_dev/cli-plugin-vue": "file:../cli-plugin-vue"
}
}The plugin package must be built (dist/) before use. Unpublished plugins work the same way — only the install source differs.
Plugin contract (for authors)
External plugins export an object (or factory) with:
| Field | Purpose |
|-------|---------|
| id | Unique preprocessor name |
| includeGlobs | Extra glob patterns merged into the scan |
| match(path) | Whether this plugin handles a file |
| transform({ path, source }) | Returns { modules: [{ path, content }] } |
| staticSinks? | Declarative callee-based sink rules (CLI converts to internal detectors) |
No dependency on @eventra_dev/eventra-cli is required — the host CLI loads the plugin, converts staticSinks into its own internal sink detectors, and calls transform() on matching files; the plugin never imports anything from the CLI itself.
Only official @eventra_dev/cli-plugin-* packages can be loaded. eventra.json is meant to be committed to git, so its plugins array is effectively PR-editable; since the CLI import()s whatever is listed there, an arbitrary specifier would be arbitrary code execution on every machine that runs sync/check/watch. Because @eventra_dev is an npm scope only the Eventra maintainers can publish to, restricting to @eventra_dev/cli-plugin-* means nothing outside that scope can ever be loaded this way. Any other entry in plugins is skipped with a console warning instead of being imported — third-party/community plugins aren't supported today.
Configuration
{
"apiKey": "",
"endpoint": "",
"events": [],
"functionWrappers": [],
"plugins": [],
"sync": {
"include": ["**/*.{ts,tsx,js,jsx}"],
"exclude": ["node_modules", "dist", ".next", ".git"]
}
}| Field | Description |
|-------|-------------|
| apiKey | Legacy inline API key — leave empty. Use EVENTRA_API_KEY or eventra.local.json instead (see eventra send) so a real key never ends up in this committed file |
| endpoint | Custom eventra send target (e.g. self-hosted). Non-default values from this file need one-time local approval — see eventra send |
| plugins | @eventra_dev/cli-plugin-* specifiers to import() at startup. Anything outside that scope is skipped (see Plugin contract) |
| sync.include | Base glob patterns; plugin includeGlobs are merged automatically |
| sync.exclude | Paths skipped during scan |
eventra init/eventra send never write a real key into this file — they write to eventra.local.json instead, which is automatically added to .gitignore.
How it works
- Load built-in plugins (
eventra-sdksink detector) and any packages listed inplugins. - Glob project files (
sync.include+ pluginincludeGlobs). - Run file preprocessors (e.g.
.vue→ virtual.tsmodules). - Load sources into an incremental TypeScript program (with SDK type shim).
- Phase 1 — find
Eventrainstances from@eventra_dev/eventra-sdkand register function wrappers that call.track(). - Phase 2 — resolve static event names and wrapper propagation chains (sink detector chain includes plugin sinks).
- Write results to
eventra.json.
watch tracks disk source files (including .vue), re-runs preprocessors on change, and incrementally updates the engine.
No runtime execution. No monkey-patching.
Requirements
- Node.js 18+
- TypeScript/JavaScript source using
@eventra_dev/eventra-sdk
Test Coverage
Two test layers, 445 unit tests + 12 e2e fixtures + 3 check exit-code scenarios + 1 watch scenario.
100% statement/branch/function/line coverage (v8 provider, pnpm test:coverage), enforced via a coverage.thresholds block in vitest.config.ts so a regression fails the build instead of silently slipping under 100%. This measures the vitest unit layer only; the e2e/check/watch fixtures run against the compiled dist/ binary via tsx tests/run.ts and aren't coverage-instrumented. A handful of genuinely unreachable defensive branches (TS-internal invariants — e.g. an aliased import symbol is always fully resolved before its own declarations are ever inspected — that don't hold a second outcome in practice) are marked with /* v8 ignore */, with an inline comment explaining why, rather than tested.
Unit tests (vitest) — 39 suites covering core modules:
| Module | Covers |
|---|---|
| ImportGraph | Forward/reverse edges, cycles, stale-edge cleanup, file removal |
| Scheduler | Batch coalescing, last-write-wins per file, sequential bursts, error propagation |
| DocumentRegistry | Path normalization, version bumping, no-op on identical content, ensure() from disk |
| CompilerContext | Stage/update/remove files, resolveModule with tsconfig.json paths, source-file enumeration |
| EventraEngine | Direct calls, SDK isolation, cross-file wrappers, file updates, file removal, wrapper filtering |
| PluginRegistry | Built-in SDK sink, preprocessors, virtual-path mapping, include-pattern dedup |
| external plugin adapter | Transform output mapping, static sink registration, invalid result rejection |
| external plugin dynamic sink | A non-literal argument at a plugin-declared static sink surfaces as a dynamic occurrence instead of being silently dropped |
| vue-shaped/svelte-shaped/astro-shaped external plugin | Adapter path for script + template virtual modules and staticSinks, one suite per framework |
| processFile | Script-kind detection, import/export specifier extraction |
| extractTemplateExpressions | Vue/Svelte/Astro attribute patterns |
| config | normalizeConfig (sort events, dedupe wrappers, defaults, preserve apiKey/endpoint/sync/plugins); resolveApiKey env-var > eventra.local.json > legacy-inline priority, saveLocalApiKey's gitignore-append |
| scanResults (buildConfigFromScan) | Replace events + wrappers, preserve everything else |
| hash | Stability and uniqueness |
| cross-file SDK detection | Direct .track() and wrapper calls resolve across files even when the call-site file never imports the SDK package itself |
| dynamic event reporter | Dynamic vs. static call classification, end-to-end registerDynamicEventReporter invocation |
| load plugins | Trusted-specifier allowlist (^@eventra_dev/cli-plugin-[a-z0-9-]+$), accepted/rejected plugin shapes |
| Nuxt auto-import compatibility | Ambient declare const x: typeof import(...) shape resolution — a hand-written fixture, not a real nuxt build |
| property propagation | Direct/optional/nested property access, destructured/aliased/nested-destructured params, element access, enum member access (same-file and cross-file), wrapper detection surviving a cast/non-null assertion, and direct object-literal payloads to track() staying ignored (including when cast) |
| send() | Endpoint trust-on-first-use (TOFU): default-trusted, blocked-until-approved, approve-then-persists, re-block-on-change, env-var-bypasses-approval |
| wrapper detector plugin | A custom WrapperDetector (e.g. a template-literal wrapper shape the built-in analyzer can't cover) alongside the built-in fallback |
End-to-end fixtures — 12 isolated TS projects scanned via eventra sync:
| Fixture | Covers |
|---|---|
| sdk/direct | Plain tracker.track() calls |
| frontend/react, frontend/next, frontend/vue | Framework-specific code shapes |
| backend/node, backend/express, backend/nest | Backend wrappers and middleware chains |
| wrappers/function | Local wrapper functions, methods, objects, ternaries, templates |
| wrappers/barrel | export * from "./tracker" re-exports |
| wrappers/default-export | export default function trackFeature propagation |
| wrappers/path-aliases | tsconfig.json paths mapping (@app/*) |
| watch-incremental | Engine state across sequential file updates (matches sync output) |
eventra check exit-code scenarios:
- Drift → exit
1 --fixwrites scan results intoeventra.json→ exit0- Parity (no drift) → exit
0
eventra watch scenario: a real watch process detects a brand-new file (created after startup) and its .track() call lands in eventra.json.
Run locally:
pnpm --filter @eventra_dev/eventra-cli test # unit + e2e + exit codes
pnpm --filter @eventra_dev/eventra-cli test:unit # vitest only
pnpm --filter @eventra_dev/eventra-cli test:e2e # fixtures + check exit codesNode version note: the CLI itself supports Node 18+ (see
engines), butvitest@4depends onnode:util'sstyleText, which requires Node ≥ 20.12 — running the test suite on an older Node 20.x patch (or Node 18) fails to start. CI runs the matrix on Node 20 and 22; locally, use the version pinned in the repo's.nvmrc.
License
MIT
