Skip to content

About

Static analysis that lists every analytics event in your TypeScript/JavaScript code, including wrappers and props. Finds features that exist in code but never fire. Optional PostHog, Segment, Amplitude and GA4 call detection.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Eventra CLI

npm version npm downloads tests unit tests e2e fixtures coverage node TypeScript license

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 - but you can opt in to a third-party library with sinks (PostHog, Segment, Amplitude and Google Analytics 4 have built-in presets). 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-cli

To try it without installing, npx -p @eventra_dev/eventra-cli eventra init --yes works in most folders. A repository that sets devEngines.packageManager to something other than npm makes npm refuse with EBADDEVENGINES: install the CLI as a dev dependency there and run it with that package manager (pnpm exec eventra sync).

Your project should use the runtime SDK:

pnpm add @eventra_dev/eventra-sdk

Quick Start

eventra init
eventra sync
eventra check
eventra send

What 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" });

A wrapper can call another wrapper, at any depth and across files. trackEvent("x") is found even when it only reaches the SDK through capture:

// capture.ts
export function capture(name: string, props?: object) { posthog.capture(name, props); }

// track.ts
export function trackEvent<E extends string>(event: E) { capture(event); }

// page.ts
trackEvent("checkout_started");

Functions handed back by a hook count too (const analytics = useAnalytics(); analytics.capture("x"), where the hook ends with return { capture }, or hands back a function through useCallback or useMemo, returned directly or kept in a variable first, as a typed capture hook does; also as a member of the returned object, return { track: useCallback(...) } or useMemo(() => ({ track }), []), and taken out with const { track } = useTrack(), even when the react types are not installed), and so does a library loaded on demand (import("posthog-js").then(({ default: ph }) => ph.capture(name)), await import(...)).

Props and inputs of any name

A component that sends one of its props (an input, in Angular) to a tracking call is found at every place it is used, whatever the prop is called - it does not have to be event. The CLI looks at what the component does with the prop, not at its name:

function Button({ analyticsName }: { analyticsName: string }) {
  return <button onClick={() => tracker.track(analyticsName)} />;
}

<Button analyticsName="checkout.cta" />   // found: "checkout.cta"
<Button label="Pay" />                    // label never reaches track(): not an event

This covers a prop handed on through other components, React.memo and forwardRef, local aliases of the props (const { track } = props, const name = props.track), spreads the analysis can see (<B {...{ n: "x" }} />, <B {...consts} />, <Inner {...props} /> and <Inner {...rest} /> that forward what they were given, except the props named beside the rest; a property written after the spread wins, as at runtime; v-bind="obj" in Vue and {...obj} in Svelte and Astro are read the same way on a component) and class components (class C extends React.Component or PureComponent: this.props.x, and const { x } = this.props in any method of the class). It works in React and, with the framework plugins, in Vue (defineProps), Svelte (export let and $props()), Astro (Astro.props) and Angular (@Input(), input()); see each plugin below. A prop the component never tracks is not an event. The plain event="..." attribute keeps working on any tag.

What is not followed

These are limits, not bugs: the component is scanned as before, so nothing already found is lost, but a prop that only they use is not found.

Where Not followed
React / JSX a spread of something the analysis cannot see into ({...load()}, {...unknownProps}); a prop's default when the attribute is omitted; components from node_modules; a value that comes from a loop variable (items.map(i => <B n={i.name} />)) is a dynamic occurrence, not an event
Vue an Options API component whose export default is not the last statement, or whose props is not written out in the object; this inside a nested function; components registered by name and <component :is>; Nuxt auto-imported components (no import); a normal <script> with its own export default
Svelte <svelte:component this={X}>; $$props / $$restProps; a mixed prop value (y="p-{x}"; on event it is read as a template literal); an instance script with export default, export * or an import after code
Astro define:vars that is not an object written out (define:vars={vars}) or a script that has imports of its own; boolean attributes; template literals in props (on event they are read); member tags (<Foo.Bar>); non-default imports; a value export or an import after code in the frontmatter; a prop put into an attribute string (onclick={track("${x}")}) is text, not a call
Angular an inline template: with ${} in it; a child reached only through an NgModule in another folder; inputs: [] metadata; directives; aliases that are not plain identifiers; class selectors and :not()

A name has to be a plain string the CLI can resolve. Anything it cannot is listed under "Dynamic event names" after sync and check instead of becoming an event. A tracking call inside a wrapper or component itself is listed apart, under "Inside a wrapper": what it tracks is whatever each caller passes, the callers that pass a literal are found, and a caller that passes something it does not know is listed above on its own line. A name that is not known where it is written says why: env (read from process.env and the like), variable, template (a template string with an unknown part), call (the result of a call) or expression. Only those count for --strict.

Monorepos

Each import is resolved with the compiler options of the nearest tsconfig.json above the file that writes it (a config that sets no paths keeps the root's aliases; node_modules is never read for options), so an app with its own @/* or ~/* aliases works without a tsconfig at the repository root. When a root tsconfig.json only lists references (the usual Vite and Vue layout), the aliases of the referenced config are used. Packages of the same repository (workspaces or pnpm-workspace.yaml) are found by their folder (@acme/lib, @acme/lib/hooks/use-x, also when the package keeps its sources in src/), so the scan does not need node_modules to link them. A real install still takes precedence.

sync, check, watch and send on a machine with room to spare (a container's memory limit counts) start themselves again with a larger memory limit (half of the machine's memory, at most 8 GB), unless you set --max-old-space-size yourself (for a shared CI runner, NODE_OPTIONS=--max-old-space-size=2048 eventra check keeps one process with exactly that limit); init and --version never do. A new eventra.json written by init leaves out tests and coverage output (see sync.exclude); a config that already exists keeps scanning what it scanned.

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");

Third-party analytics (sinks)

Already have analytics in place? Declare it in eventra.json and the CLI treats its calls as event sources too, with no SDK swap. Wrappers, constants, cross-file imports and JSX component props resolve exactly as they do for the Eventra SDK.

{
  "sinks": [
    { "preset": "posthog" },
    { "preset": "segment" },
    { "preset": "amplitude" },
    { "preset": "ga4" },
    { "callee": "analytics.track" },
    { "callee": "gtag", "nameArg": 1, "when": { "arg": 0, "equals": "event" } }
  ]
}
Field Meaning
preset A built-in rule set: posthog, segment, amplitude or ga4 (see below)
callee Dotted call path, matched as a suffix: analytics.track also matches this.analytics.track, but not myanalytics.track
nameArg Zero-based index of the event-name argument (default 0)
nameProperty If that argument is an object literal, read the name from this property (e.g. event)
modules Only match when the receiver comes from one of these npm packages (imported, re-exported, typed, or built from them)
when Only match when the argument at arg is the string literal equals, e.g. { "arg": 0, "equals": "event" } for gtag("event", name)
Preset Detects
posthog posthog.capture("x"), the usePostHog() hook (from posthog-js/react or @posthog/react) under any variable name (also through re-exports like export { usePostHog as useAnalytics }), and posthog-node's client.capture({ distinctId, event: "x" }), including typed class fields
segment analytics.track("x", props) (analytics.js, window.analytics), analytics-node's client.track({ userId, event: "x" }), and clients from @segment/analytics-next, @segment/analytics-node, @segment/analytics-react-native or analytics-node under any variable name, including const [analytics] = await AnalyticsBrowser.load(...)
amplitude amplitude.track("x"), amplitude.track({ event_type: "x" }), amplitude.logEvent("x"), amplitude.getInstance().logEvent("x"), and clients from the @amplitude/* browser, node, react-native and unified packages or amplitude-js
ga4 gtag("event", "x", params) (also window.gtag and nuxt-gtag's const { gtag } = useGtag()), sendGAEvent("event", "x", params) from @next/third-parties, nuxt-gtag's useTrackEvent("x"), vue-gtag's this.$gtag.event("x") and event("x") / const { event } = useGtag(), and react-ga4's ReactGA.event("x") / ReactGA.event({ category, action: "x" }). Other gtag commands (gtag("config", id), gtag("set", ...)) are not events and are ignored

Rules are plain data and never run code, so they are safe to commit. A rule with a typo, an invalid when or an unknown preset is skipped with a warning. A function with its own name (trackEvent({ event: "x" })) is not covered by a preset: add a rule such as { "callee": "trackEvent", "nameProperty": "event" }.

The name rules below apply to these events too. Spaces and colons are allowed, so names like App: User Sign Up or Order Completed are kept as written. PostHog's $-prefixed names ($pageview, $pageleave) are kept as well. Names with other characters, such as one ending in !, are excluded from the scan result.


Event name rules (aligned with SDK)

Rule Limit
Max length 64 characters (same as SDK): a longer name is cut to 64, as track() does, so the catalog holds what is sent
Characters Letters of any language (Заказ оформлен, 购买完成), digits, the space, and $ : _ . / - [ ] ( ) # @ + & , ' (so App: User Sign Up, $pageview, [Amplitude] Page Viewed and User's Profile Viewed work). Emoji, tabs, newlines, !, ? and everything else are rejected
Position First argument of .track(name, options?)

The second argument (userId, properties) is not used as the event name.

A literal name that is empty or has any other character is not added to events and is not a dynamic occurrence. After the scan the CLI lists each one in a note, with its file, line and the reason, so a missing event is not a mystery. --strict fails on them (see eventra sync).


What is ignored

By default only the Eventra SDK is read. Calls to other analytics are ignored unless you enable them with sinks.

// Not from @eventra_dev/eventra-sdk (and no matching `sinks` rule)
track("legacy");
analytics.track("ga");
segment.track("x");

// Wrong API shape for SDK (object as first argument)
tracker.track({ event: "click" });

Commands

eventra init

Creates eventra.json. It asks for an API key and an endpoint, then looks through package.json (and the package.json of every workspace member in a monorepo: workspaces or pnpm-workspace.yaml) for analytics libraries and for the framework (vue, svelte, astro, @angular/core), and writes the matching plugin into plugins when it is installed. If it is not, init prints the install command and the line to add instead: sync stops on a plugin it cannot load, so init never writes one that would make the next sync fail:

Found analytics in package.json: posthog-js; @segment/analytics-next
? Also read events from these analytics calls (sinks):
❯◉ posthog (posthog-js)
 ◉ segment (@segment/analytics-next)

Everything found is pre-selected; press Enter to accept or untick what you do not want. The chosen presets are written to sinks in eventra.json. Recognised packages:

Preset Packages
posthog posthog-js, @posthog/react, posthog-js-lite, posthog-node, posthog-react-native
segment @segment/analytics-next, @segment/analytics-node, @segment/analytics-react-native, @segment/snippet, and the older analytics-node, @segment/analytics.js-core
amplitude @amplitude/analytics-browser, @amplitude/analytics-node, @amplitude/analytics-react-native, @amplitude/unified, @amplitude/marketing-analytics-browser, and the older amplitude-js
ga4 react-ga4, vue-gtag, @next/third-parties, nuxt-gtag, @types/gtag.js, and the older vue-gtag-next

Detection reads dependencies only. A tag pasted straight into the HTML (a Segment or gtag.js snippet with no package) is not seen: add the preset to sinks yourself. In a monorepo only the block-list form of packages: in pnpm-workspace.yaml is understood (not packages: [a, b]), and workspace patterns that are absolute or contain .. are ignored. If eventra.json already exists, init asks before overwriting it (the default answer is no). eventra init --yes never prompts: it uses the default endpoint, accepts every detected preset, and leaves an existing eventra.json untouched, so it is safe in scripts and CI.

eventra sync

Full project scan → updates eventra.json:

  • events — discovered event names
  • functionWrappers — detected wrapper functions

A file that cannot be analysed (a broken .vue, .svelte, .astro or .html, or an unreadable file) is skipped, and sync prints one summary at the end listing each skipped file and why. While anything is skipped, events already in eventra.json are kept, because a missing event may simply live in a skipped file. The exit code stays 0; eventra sync --strict exits 1 if any file was skipped, a file has a syntax error, an event name was rejected, a file is over the size limit (it is not analysed, and its events leave the catalog), or a dynamic name could not be resolved at all (see Dynamic names).

A .ts/.tsx/.js/.jsx file the TypeScript parser reports syntax errors for is still scanned, but it is listed in a separate note: events after the error may be missed. It is not a skipped file, so it does not keep known events, but --strict fails on it. Event names the CLI cannot accept (see Event name rules) are listed in a note as well. When the scan was not clean (skipped files, syntax errors, rejected or dynamic names, files over the size limit), the last line says so: The scan was not clean: 1 skipped file, ....

eventra check

Compares config with the current scan (events + wrappers). Exit code 1 on drift. Files with syntax errors and rejected event names are listed in notes, as for sync. When files were skipped, known events that were not found are not reported as removed (they cannot be verified) and the skipped files are listed.

check prints All good only when the events match and the scan was clean. If the events match but something was skipped, has a syntax error, was rejected or is a dynamic name it could not resolve, it prints No drift, but the scan was not clean: ... instead. The exit code is still 0 for that, so an existing pipeline does not start failing; eventra check --strict exits 1 on any of them, and on a file over the size limit. Use --strict in CI.

Dynamic names

A name computed at run time (track(getName()), a remote import, import()) cannot be read from the code. List the names such a call can produce in dynamicEvents in eventra.json: they count as found events, so sync, check and watch agree on them, and --strict stops failing on the dynamic names it lists. Declaring them is your acknowledgement that the list is complete.

{ "dynamicEvents": ["checkout.started", "checkout.completed"] }

For a Nuxt plugin that provides a tracking function (useNuxtApp().$trackFeature("x"), or const { $trackFeature } = useNuxtApp()), add a sinks rule for it: { "callee": "$trackFeature" } reads the name from the first argument whatever the receiver is.

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 an endpoint (init writes the default https://api.eventra.dev/api/v1/cli/events; EVENTRA_ENDPOINT, when set, takes priority over the endpoint in eventra.json). Use https: plain http works (and is silent for localhost, 127.0.0.1 and [::1]) but on any other host send warns that the API key travels unencrypted. send never follows a redirect, so the API key cannot be forwarded to another host. Events are POSTed to POST /api/v1/cli/events and marked as non-billable on the backend.

Key access. send needs a key whose access is CLI or Everything. A key created for the SDK (SDK: send events) is refused with 403, so a key that sits in a public browser bundle cannot rewrite your code catalog. Create a separate CLI key under the project's API keys in the dashboard.

API key resolution (checked in this order, never written to eventra.json):

  1. EVENTRA_API_KEY environment variable — recommended for CI
  2. eventra.local.json (created by eventra init/eventra send, automatically gitignored)
  3. legacy inline apiKey in eventra.json (deprecated; sync, check, watch and send print a warning when it is set. If a real key was ever committed, create a new one in the dashboard and revoke the old one)

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. If it has to ask for the key, what you type is hidden. 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, and a pull request cannot change it). 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-endpoint

This 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.

EVENTRA_ENDPOINT always wins over eventra.json: when it is set (and not empty), that is where events and the API key go, whatever the committed file says. The committed endpoint is only used when the variable is unset or empty, and then it needs the approval described above. (Before 2.6.3 the committed value won and the variable only skipped the approval prompt; a pull request could use that to redirect a CI run.)

Catalog snapshot. Besides the event list, send scans the project once more and tells the server how trustworthy that list is, so the dashboard can compare what is in your code with what actually arrived. It adds four optional fields to the request: complete, dynamicCount, branch and cliVersion.

  • complete is true only when the scan skipped no file, hit no error, and eventra.json matches what the scan just found. The server marks names that are missing from a complete snapshot as removed from code, so anything doubtful is false (a file that could not be parsed, a failed scan, or an eventra.json that is out of date: run eventra sync first). A request that is not complete never removes anything.
  • dynamicCount is the number of event-name call sites that could not be resolved to any string (for example track(name) with an unknown name). The dashboard uses it to warn that such names may show up as unexpected events.
  • branch is the current branch, read from the usual CI variables (GITHUB_REF_NAME, CI_COMMIT_REF_NAME, BRANCH_NAME, BITBUCKET_BRANCH, CIRCLE_BRANCH, BUILDKITE_BRANCH, VERCEL_GIT_COMMIT_REF) and otherwise from git; it is left out when unknown (for example a detached checkout with no CI variable). cliVersion is the CLI version.

Run send from the main branch in CI: a snapshot from a feature branch describes that branch. A server that predates these fields rejects them, so send repeats the request once with the plain event list. The extra scan adds the same time as one eventra sync.

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 .vue file 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 .ts file (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
  • reads calls written anywhere in the template: @click/v-on handlers (several statements, multi-line arrows), {{ }} interpolations and the expressions of v-bind, v-if, v-show, v-model, v-html, v-text
  • Nuxt .vue files (pages/layouts/components) are handled the same way; auto-imported composables resolve once the project has run nuxt prepare/nuxt dev
  • finds a prop of any name that a <script setup> component (defineProps) or an Options API component (props: [...] read as this.x) sends to a tracking call, at every place the component is imported and used; components that are not imported are not followed (see Props and inputs of any name)

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 .svelte file with the real Svelte compiler (svelte/compiler) into a single virtual TypeScript module combining context="module" and instance <script> blocks
  • extracts track() calls from the script exactly like a regular .ts file (direct calls, wrappers, propagation); Svelte 5 runes ($props, $state, …) pass through untouched
  • reads calls anywhere in the markup: handlers, {expr}, {@html}, block expressions ({#if}, {#each}, {#key}, {#await}), use:/class:/transition: directives and attribute values
  • 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; the {event} shorthand and a mixed value (event="a-{b}", read as a template literal) work too
  • finds a prop of any name (export let or $props()) that a component sends to a tracking call, at every place the component is imported and used; <svelte:component> is not followed (see Props and inputs of any name)

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 .astro file 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 .ts file (direct calls, wrappers, propagation); Astro.props and other globals pass through untouched
  • reads calls in template expressions ({track("x")}, attr={expr}, {...spread}) and in the page's <script> tags, each as a module of its own, except <script define:vars={{ ... }}>, which is read where the frontmatter's variables are in scope (quoted onclick="..." strings are browser text and are not read)
  • 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 and event=`a-${b}`); dynamic bindings resolve through the same module scope as the frontmatter, or surface as a dynamic occurrence if unresolvable
  • finds a prop of any name (Astro.props) that a component sends to a tracking call, at every place the component is imported and used (see Props and inputs of any name)

sync.include does not need **/*.astro manually. See @eventra_dev/cli-plugin-astro for details.

Angular (.html via templateUrl, and inline template:)

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 .ts file (direct calls, wrappers, propagation) - no plugin needed for that part
  • reads calls in the template: (click) handlers, [input] bindings, {{ }} interpolations, *ngIf and @if/@switch/@for, written against the component class (track('x') is this.track('x')); an .html file no component names as its templateUrl is skipped when it does not parse
  • detects event="feature_name" attributes on any tag, including *ngIf/*ngFor and the @if/@for/@switch block 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
  • finds an input of any name (@Input(), input()) that a component sends to a tracking call, at every template that uses the component by its selector (a child with an inline template: included); NgModule-only children and inputs: [] are not followed (see Props and inputs of any name)
  • use [attr.event]="expr", not [event]="expr", for a dynamic binding on a plain HTML element (the plugin tells the CLI when it sees [event] on a native tag, and sync/check print a note "Notes from plugins"; it never changes the exit code) - [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. A component with an inline template: "..." (a string or a backtick string with no ${}) is read from its .ts file the same way: the template's calls are added to the component class in that file, so the file keeps its own line numbers. The .ts file has to be matched by sync.include, as it always was; the plugin does not widen the scan. 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": [],
  "sinks": [],
  "dynamicEvents": [],
  "sync": {
    "include": ["**/*.{ts,tsx,js,jsx}"],
    "exclude": ["node_modules", "dist", ".next", ".git"],
    "maxFileSize": 2000000
  }
}

This is the full shape. A file written by eventra init only contains plugins, sinks and dynamicEvents when they are not empty, and sync.maxFileSize only when it differs from the default (and its sync.exclude also lists coverage, test files and __tests__ / __mocks__ folders); add them by hand when needed.

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). EVENTRA_ENDPOINT overrides it. Non-default values from this file need one-time local approval, and a non-local http endpoint produces a warning - see eventra send
plugins @eventra_dev/cli-plugin-* specifiers to import() at startup. Anything outside that scope is skipped (see Plugin contract)
sinks Third-party analytics calls to treat as event sources, e.g. [{ "preset": "posthog" }] (see Third-party analytics)
dynamicEvents Names that a computed track(...) call can produce, declared by hand (see Dynamic names)
sync.maxFileSize Largest file, in characters, that is analysed. Default 2,000,000; a bigger file is listed as ignored
sync.include Base glob patterns; plugin includeGlobs are merged automatically
sync.exclude Paths skipped during scan. Default for a config that has none: node_modules, dist, .next, .git. eventra init writes a longer list into a new eventra.json: that plus coverage, test files (**/*.{test,spec}.{ts,tsx,js,jsx}) and __tests__/__mocks__ folders. A plain name such as node_modules or dist matches at any depth (nested packages/*/node_modules too); entries containing / or glob characters are used as written

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

  1. Load built-in plugins (eventra-sdk sink detector) and any packages listed in plugins.
  2. Glob project files (sync.include + plugin includeGlobs).
  3. Run file preprocessors (e.g. .vue → virtual .ts modules).
  4. Load sources into an incremental TypeScript program (with SDK type shim).
  5. Phase 1 — find Eventra instances from @eventra_dev/eventra-sdk and register function wrappers that call .track().
  6. Phase 2 - resolve static event names and wrapper propagation chains, a wrapper that calls another wrapper included (sink detector chain includes plugin sinks).
  7. 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.

Scan time and memory grow with the project: a repository of several thousand files takes tens of seconds and a few GB of memory (the CLI raises its own memory limit, see Monorepos). A file over 2 MB (or over sync.maxFileSize) is not analysed, is listed in the closing summary, and fails --strict, because the events in it are missing from the scan; put a generated bundle in sync.exclude, or raise sync.maxFileSize if it is your own source.


Requirements

  • Node.js 18+ (eventra watch needs Node.js 20.19+, which its file-watching dependency requires; init, sync, check and send run on 18)
  • TypeScript/JavaScript source using @eventra_dev/eventra-sdk

Test Coverage

Two test layers, 1164 unit tests + 14 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) - 66 test files 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, nearest tsconfig.json per file, references, workspace packages without an install
wrapper chains A wrapper calling a wrapper (depth 2 and 3), either file order, generics, destructuring, cycles, watch updates
hook wrappers return { capture }, a function returned through useCallback or useMemo, dynamic import() receivers, .then callbacks, promise-typed getters
respawn Heap sizing (a container's lower memory limit counts), only scanning commands respawn, signals reach the child, no second respawn, explicit --max-old-space-size respected
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; a real nuxt prepare project was also verified manually, but that check is not automated
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
configured sinks sinks rules and the PostHog, Segment, Amplitude and GA4 presets: every call shape, receivers traced through imports, re-exports, hooks, typed fields and aliases, the when guard, spaced names, wrong-typed config, circular chains
sink presets The package table behind the presets: detection per package, order, legacy and detect-only flags, rules vetted only against their own preset's packages
project dependencies package.json and workspace reading for eventra init (workspaces, pnpm-workspace.yaml, negations, caps, patterns that leave the project, unreadable files)
init eventra init prompts, pre-selected presets, written config, exact console output, API key kept out of eventra.json
preset package check The registry verdicts behind pnpm check:presets (missing, deprecated, stale, legacy)
license LICENSE is the complete MIT text, its holder is the package author, and both match package.json

End-to-end fixtures - 14 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/*)
sinks/presets PostHog, Segment, Amplitude and GA4 side by side, including gtag("config") that must not count
sinks/spaced-names Names with spaces and colons through the SDK, PostHog (client and server shapes), a custom rule, wrappers and JSX, plus calls that must be ignored
watch-incremental Engine state across sequential file updates (matches sync output)

eventra check exit-code scenarios:

  • Drift → exit 1
  • --fix writes scan results into eventra.json → exit 0
  • 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 codes

Node version note: the CLI itself supports Node 18+ (see engines), but vitest@4 depends on node:util's styleText, 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 Node 20.19 or newer (20.x or 22.x).


License

MIT, copyright (c) 2026 Yuriy Korolik. See LICENSE.

About

Static analysis that lists every analytics event in your TypeScript/JavaScript code, including wrappers and props. Finds features that exist in code but never fire. Optional PostHog, Segment, Amplitude and GA4 call detection.

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages