ravensight-doctor
v0.4.2
Published
Static integration linter for Ravensight game analytics SDKs. Scans a game project, checks the integration, and optionally reports to the Ravensight API.
Downloads
894
Maintainers
Readme
Ravensight Doctor
Static integration linter for Ravensight player analytics SDKs.
Point it at a game project and it detects which engine and Ravensight SDK you are using, checks the integration for the mistakes that actually cost people data, prints a report, and can submit that report to your Ravensight dashboard. Zero runtime dependencies, one command in CI.
- API base:
https://api.ravensight.io - Docs: https://ravensight.io/docs/
- Node 18 or newer
Verification status
The checks are written and reviewed against the live report API contract and covered by tests over fixture projects, but the scanner is young and deliberately conservative: it is line based and prefers missing a clever case over reporting a false positive. If it flags something that is actually fine, or misses something it should have caught, please open an issue with a snippet that reproduces it.
What it checks
| Finding | Severity | What it means |
| --- | --- | --- |
| sdk_missing | error | An engine was detected but no Ravensight SDK is in the project. |
| wrong_key_type | error | A secret that must never ship in a game build (a gt_mcp_ token, a Stripe live key, an AWS access key) is in the tree. Only a masked prefix is ever printed. |
| event_name_too_long | error | A track call uses an event name literal over 200 characters, which the server rejects. |
| tracking_unverified | warning | The doctor could not check your events: either it found track calls but none names its event with a string literal (usually a wrapper that passes a variable), or the project has an ingest key but no track call it recognises. Makes the status inconclusive. Name your wrapper with --track-fn to resolve it. |
| calls_outside_paths | warning | A call matches a track function name, but every trackFunctions entry for that name has paths and none covers its file, so it was not checked. Lists up to 10 of them as file:line (form). Add a paths entry that covers those files, or an entry without paths. |
| tracking_unresolved | warning | Some track calls do not name their event with a string literal on the call line, so those names were not checked. Lists up to 20 of them as file:line. When they are 25 percent or more of all recognised calls the status is inconclusive. A concatenated or interpolated name ("level_" + str(n)) counts as unresolved here and also keeps its event_name_cardinality warning. |
| no_ingest_key | warning | No gt_live_ ingest key found outside test paths. Fine if you inject it at runtime, fatal if you forgot it. With ingestKey.source "build" or "env" in the config it is an info instead. See Keys injected at build time or read from the environment. |
| bundle_key_missing | warning | --bundle was given and its Info.plist holds no ingest key of the right shape. |
| event_name_cardinality | warning | An event name embeds a multi digit number or string interpolation or concatenation. Identifiers belong in event data, not names. |
| large_inline_payload | warning | An object literal inside a track call spans more than 8KB of source. The server caps event data at 8KB serialized. |
| live_check_failed | error | --live could not deliver its test event: no usable key, a rejected key, tracking switched off, a refused session or event, or no network. The note says which step failed. |
| live_ping_unconfirmed | warning | --live sent its test event and the API accepted it, but it was not read back within 20 seconds. |
| contract_event_missing | warning | An event the contract names (in events, a pairs_with or earned_once) is not a literal event name in any track call. See The event contract. |
| contract_prop_missing | warning | A literal props object at a call site lacks a key the contract requires for that event. Reported with file:line. |
| contract_unmatched | info | The contract names no event found in the scan at all, so nothing was compared. |
| name_without_kind | info, or warning with kinds: "explicit" | An event name that Ravensight's level funnel reads by substring as a level start, completion or death (checkout_started contains start), at a call whose literal props have no rs_kind or that sends no props. Detection by name is a documented default, so this is an info unless the config says "kinds": "explicit". Fix: add rs_kind, or none to opt out. See Event names that read as a level event. |
| contract_prop_unproven | info | A literal props object at a call through your own track function lacks a required key, and the wrapper may add it centrally: not provable statically; the runtime trace is the evidence. Declare the keys with enriches to silence it. |
| bundle_key_present | info | --bundle was given and its Info.plist holds an ingest key of the right shape (never printed). Replaces no_ingest_key. |
| forwarder_unmatched | info | A name in forwarders has no definition or closure assignment in the scanned files. |
| no_track_calls | info | The SDK is installed but nothing calls track yet, and no ingest key is wired up either. |
| track_fn_unused | info | A track function you named with --track-fn or trackFunctions is never called. Check the spelling. |
| flush_on_exit_missing | info | A hand rolled integration has track calls but no flush or exit handling, so the last batch is lost on quit. SDK engines handle this themselves and never get this finding. |
Engines detected: Godot, Unreal, Unity, iOS, Android, JavaScript, Odin, C++,
and custom for everything else. Detection is first match wins, and the whole
tree is scanned either way.
Status
The report status is one of four values, in this order of precedence:
| Status | When |
| --- | --- |
| fail | At least one error finding. |
| inconclusive | A tracking_unverified finding (no literal event names at all), or unresolved calls making up 25 percent or more of the recognised track calls. Either way the doctor could not see enough of how your project tracks events for a clean result to mean anything. |
| warn | Any other warning. |
| pass | No errors or warnings. Only a real pass prints "Your integration looks healthy." |
Three lines of evidence
Source, a trace and a live delivery prove different things, so the report keeps them apart. After the findings it prints:
Static: pass (2 findings not provable statically)
Runtime trace: warn
Live delivery: storedStaticis the verdict of the source scan alone (pass,warn,inconclusiveorfail), with the number ofcontract_prop_unprovenfindings: keys only a trace can show.Runtime traceispassorwarnfor the--tracereplay, ornot run.Live deliveryisstored(read back),accepted(not read back in time),failedornot runfor--live.
The JSON has the same as
evidence: { static, trace, live, notProvableStatically }. The top-level
summary.status stays the static verdict, so a pipeline that reads it
behaves as before: trace findings never change it, and the only thing
--live adds to it is its own live_check_failed error or
live_ping_unconfirmed warning, as since 0.3.0.
So the Static line can read pass while Status reads fail: that is a
clean source scan followed by a --live that could not deliver its event.
How --strict reads the three lines:
| Line | Exits 1 under --strict | Exits 1 without it |
| --- | --- | --- |
| Static | warn, inconclusive or fail | fail |
| Runtime trace | warn | never |
| Live delivery | accepted (not read back) or failed | failed |
Inventory
Every report lists what the doctor actually recognised: each track call with
its file, line, call form and literal event name (or none when the name is a
variable, or names for a ternary of two literals), the distinct event
names with their counts, the number of unresolved calls, the track
functions it looked for with their call counts (one row per
trackFunctions entry, with its paths),
how many ingest keys it found in the project and under test paths, and how
many files it scanned for calls (filesScannedForCalls) and for keys
(filesScannedForKeys). The pretty report prints a summary and --json
prints all of it.
A call inside one of your own named track functions that just passes the
function's parameter on (function logEvent(name) { rs.track(name) }) is
counted as forwarded (forwarded: true, forwardedCalls), not unresolved,
because the names are checked where your function is called. The doctor
finds the definition by the last segment of the name you gave it
(--track-fn Analytics.log_event finds func log_event(name):) and only
counts calls inside its body: up to the closing brace in C style languages,
while the indentation lasts in GDScript and Python, and up to the matching
end in Lua. The same call anywhere else stays unresolved. A parameter list
that spans several lines is not followed.
Besides function name(...), the definition can be any of these forms
(again matched on the last segment):
- Lua table constructor field:
track = function(n, d) get().track(n, d) end - Lua module field:
M.track = function(name, data) ... end - JavaScript object property:
track: function (name, data) { ... } - JavaScript method shorthand in an object literal or class:
track(name, data) { ... } - JavaScript arrow function:
const track = (name, data) => rs.track(name, data)ortrack = async name => { ... }
An arrow function with a braced body ends at its closing brace; one with an
expression body ends at the end of its statement (a ;, a , or closing
bracket outside its own brackets, or the end of the line).
A message or event handler forwards a field of its parameter instead, as the bridge on a love.js page does. Inside the body of an anonymous function passed as an argument or assigned to a property, a call whose name argument is a field of one of that function's parameters is counted as forwarded too:
window.addEventListener('message', (m) => { c.track(m.name, m.data) })bridge.onmessage = function (msg) { c.track(msg['event']) }on('x', ({ name }) => c.track(name)), a name the parameter list destructures- Lua:
bridge.on('message', function(m) c.track(m.name) end)and a table fieldtrack = function(m) c.track(m.name) end
A local declared in the handler body from an expression that names one of
its parameters counts the same way, up to three levels of locals (a local
from a local from a local), whatever order they are declared in:
Module.print = function (line) { const m = JSON.parse(line); c.track(m.name, m.data) },
const { name } = JSON.parse(line) and Lua
function(line) local m = json.decode(line) c.track(m.name) end. A local
from a literal or from something unrelated to the parameters does not;
a parameter name inside a string ("line") or as an object key
({ line: 1 }) is not a reference to it.
A named function declared on its own counts as a handler when its name
is assigned to a property anywhere in the file, before or after the
declaration (function handle(text) { ... } and later
Module.print = handle; or window.__rsBridge = handle;), with the same
rules for locals. A local declared without a value and assigned later,
even inside a try block (let m; try { m = JSON.parse(text) } catch (e)
{ return }), counts when every assignment to it in the body names a
parameter; one reassignment from a literal (m = { name: "fallback" })
leaves it unresolved.
A plain parameter passed on (items.forEach((n) => c.track(n))), a name
that is not a parameter of the enclosing handler (c.track(other.name)),
a function assigned to a local variable and a named function whose name
is never assigned to a property all stay unresolved.
Swift wrappers
A Swift game often wraps the SDK twice: emit sends an event and
emitOnce sends it only the first time a key is seen, forwarding to
emit:
enum Analytics {
static var sent = Set<String>()
static func emit(_ name: String, _ props: [String: Any] = [:]) {
Ravensight.track(name, props)
}
static func emitOnce(key: String, _ name: String, props: [String: Any] = [:]) {
guard !sent.contains(key) else { return }
sent.insert(key)
emit(name, props)
}
}
Analytics.emit("run_started", ["run_id": runId])
Analytics.emitOnce(key: "intro", "progress_unlocked", props: ["content_id": "intro"])Name both wrappers. The event name is argument 1 of emitOnce, counting
every argument, labelled or not:
npx --yes ravensight-doctor --track-fn '*.emit,*.emitOnce@1'or in ravensight.config.json:
{ "trackFunctions": ["*.emit", "*.emitOnce@1"] }The *. form counts calls through the type (Analytics.emit(); add the
bare emit and emitOnce@1 as well if you call them without the type
from inside it. A labelled argument such as name: "ending_seen" is read
as its value, in Swift and C#.
With only emit named, the emit(name, props) inside emitOnce is an
unresolved call. When an unresolved Swift call passes a parameter of the
function around it, the tracking_unresolved finding names that function,
the argument position and the --track-fn value to add (for example
--track-fn '*.emitOnce@1'), and the call's inventory row carries
suggestTrackFn.
Forwarding closures
A closure whose own parameter is passed on as the event name is a sink: its names come from whoever calls it, so its track call is forwarded, the same rule as for a message handler. In Swift and JavaScript:
var sink: (String, [String: Any]) -> Void = { name, data in Ravensight.track(name, data) }
bus.register(sink: { (name: String, data: [String: Any]) in Ravensight.track(name, data) })bridge.send = (name, data) => rs.track(name, data);
register((name, data) => rs.track(name, data));The callback of an iteration is not a sink and stays unresolved:
items.forEach((n) => rs.track(n)), _.each(names, (name) => ...),
Array.from(names, (name) => ...) and names.forEach { name in
Ravensight.track(name) } send names from a list the doctor cannot read.
The parameter must belong to the innermost closure around the call. A Swift
$0 is not followed. For a sink the heuristic cannot see, name it in
forwarders:
{ "forwarders": ["TelemetrySink.send"] }A track call inside the body of that function or closure property whose
name the doctor cannot read is then forwarded (via: "forwarders:
TelemetrySink.send" on the inventory row). Nothing else changes: calls to
TelemetrySink.send(...) are not track calls (name it in trackFunctions
too if you want their names checked). A dotted name is matched on its last
segment in a file that declares the first part (enum TelemetrySink,
class, struct, extension, const and the like) or assigns
TelemetrySink.send = directly. A name with no match is the info
forwarder_unmatched.
Native Xcode projects
The doctor reads source. An iOS or macOS game that keeps its publishable
key in an ignored .xcconfig, expanded into the built Info.plist as
$(RAVENSIGHT_INGEST_KEY), has no key in its source, so set
"ingestKey": { "source": "build" } and check the built app with
--bundle:
npx --yes ravensight-doctor --bundle build/Build/Products/Release-iphoneos/Game.appA wrapper that merges run_id, rs_kind and other keys into every payload
before Ravensight.track should list them in its trackFunctions entry's
enriches, so the contract does not count them as missing at each call:
{
"trackFunctions": [
{ "name": "*.emit", "enriches": ["run_id", "rs_kind", "schema_version", "build_id"] }
],
"ingestKey": { "source": "build" }
}Which calls count as track calls
Member calls where the name right before
.track(is exactlyrs,ravensightorRavensight, with any chain before it:rs.track(,this.rs.track(,window.rs.track(andGame.Ravensight.track(all count, whileanalytics.track(andmyrs.track(do not.Ravensight.Track((Unity),->Track((Unreal) andtrack(client, ...)(Odin, where the event name is the second argument).Your own functions, named with
--track-fnortrackFunctions(see Configuration):- A bare name such as
capturematches only an unqualified call,capture(. A call with a receiver, meaning a.,?.or::right before the name (whitespace allowed) or a->directly before it ($this->capture(), is a different function and does not count:analytics.capture(andclient?.capture(are left alone. A spaced arrow is a Kotlin or Java lambda orwhenbranch, sox -> capture("a")still counts. When a bare name was only ever called on a receiver, itstrack_fn_unusednote suggests the*.form. In Lua a:counts as a receiver too (telemetry:capture(); elsewhere a lone:is a ternary arm or an object key, sowon ? capture("a") : nullstill counts. - A name with a leading
*., such as*.capture, matches the call on any receiver (analytics.capture(,client?.capture(,this.client.capture(, Lua'stelemetry:capture() and never a barecapture(. Name both when you want both. - A dotted name such as
Analytics.logEventmust match from its first segment, soGame.Analytics.logEvent(counts andMyAnalytics.logEvent(does not. - A Lua method name such as
telemetry:track(one:before the last segment) matchestelemetry:track(andtelemetry.track(both.
Function definitions are not counted as calls, including Lua's
function x.track(,function x:track(,local function track(andfunction track(, a class methodcapture(event, data) {, and declarations with no body: a TypeScript method signature in an interface or a type literal (capture(event: string): void;) and an abstract method (abstract capture(event: string): void;). None is ever inventoried as unresolved or outside paths.- A bare name such as
When two forms match the same call (for example
*.trackand the built inrs.trackboth matchrs.track(), it is one call, credited to the most specific form, the one with the longest name.A call whose event name is a ternary of two string literals,
capture(won ? 'playoff_round_cleared' : 'playoff_eliminated'), counts both names. The call is listed once, withnames: [...]in place ofname. A nested ternary, or one with an arm that is not a string literal, stays unresolved, and so doescond && 'name'.A member of an object constant,
capture(EVENTS.registered)orcapture(EVENTS['registered']), is read as the string literal it holds when a JavaScript or TypeScript file declaresconst EVENTS = { registered: 'registered' }(also withas constorObject.freeze({ ... })). The same file is looked up first; otherwise a scanned file that exports the name (export const EVENTSorexport { EVENTS }) is used when the calling file imports it by name (import { EVENTS } from './events'), and only when every exporting file agrees on the value. One level, plain string literals only; anything else stays unresolved. The inventory row carriesconstant: "EVENTS.registered".
Track calls inside the SDK's own files are ignored, so its doc comments do not trip findings.
Comments
Calls are matched after comments are blanked out, so a commented out call is never counted. Line numbers are unchanged.
| Files | Comments removed |
| --- | --- |
| .js .mjs .cjs .ts .mts .cts .tsx .jsx .cs .swift .kt .java .odin .c .cc .cpp .h .hpp | // to the end of the line, and /* ... */ |
| .vue .svelte | <!-- --> and <style> blocks; // and /* ... */ inside <script> blocks |
| .gd .py .rb .sh | # to the end of the line |
| .lua | -- to the end of the line, and --[[ ... ]] (also --[==[ ... ]==]) |
| anything else | nothing |
In JavaScript and TypeScript (including the scripts in HTML, Vue and Svelte
files) regex literals are followed on their line, so a quote or a /*
inside /'/g, /\/*$/ or /[/*]/ is read as part of the regex, and a
' right after a letter or digit, as in the JSX text Don't, does not
start a string. A /* outside a string or regex always starts a block
comment, wherever it sits on the line. A Vue or Svelte component keeps its
markup, because a template handler such as @click="capture('x')" is a real
call; its <script> blocks are stripped as JavaScript when they are
JavaScript or TypeScript (by type or lang) and left out otherwise.
Known limit: quoted strings are only followed within one line, so a comment marker inside a string that spans lines (a template literal, a Python triple quoted string, a Lua long string) can be mistaken for a comment. Key checks read the file as written, so a key inside a comment is still reported.
Upgrading from 0.3.1: no differences expected except where a comment-stripping fix applies.
Scan scope
Key checks (ingest keys, MCP tokens and other secrets) read every file the walk below finds. Track calls are looked for in a narrower set, so tests and documentation do not count as calls:
- Test paths are left out: files under
test,tests,spec,specs,__tests__,fixtures,__fixtures__or__mocks__at any depth, and*.test.*and*.spec.*files.--include-testsbrings them back. - Docs are left out:
.md,.mdx,.rstand.txtfiles, and files underdocs,doc,examplesorexampleat any depth.--include-docsbrings them back. - HTML pages (
.html,.htm) are scanned, but only the code inside their JavaScript<script>elements: those with notype,type="module", or ajavascriptorecmascripttype. JSON,importmap, template and other script types are skipped. Page text, markup and<!-- -->comments are ignored, and comments inside the scripts are stripped as JavaScript. A web build'sindex.htmlthat callsrs.track('game_started')in a page script is counted. An HTML page under a docs directory is left out like any other docs file, and the left out line names it, for example2 docs files (HTML: docs/index.html); the JSON inventory hashtmlLeftOutOfCallScan: { count, files }, with up to 20 paths infiles. - A vendored copy of the SDK, any directory named
ravensight(for examplelib/ravensight/,vendor/ravensight/,addons/ravensight/) and any SDK file namedravensight*, is always left out.
Directory names match in any case, so Unity's Assets/Tests is a test path.
The header shows both counts and what was left out, for example:
Files: 448 scanned for calls, 6432 scanned for keys
5984 files left out of the call scan: 5120 test files, 860 docs files, 4 vendored SDK files; use --include-tests or --include-docs to scan themWhat the walk skips for every check:
- Directories never scanned, at any depth:
node_modules,.git,.import,.godot,Library,Intermediate,DerivedData,build,dist,coverage,.playwright-mcp,.cache,.next,.nuxt,.turbo,.vite,.svelte-kitandtmp. - Files never opened: images, fonts, audio, video, archives, compiled
output, source maps, coverage reports, minified
.min.jsand.min.css, logs, and lockfiles such aspackage-lock.json,yarn.lockandpnpm-lock.yaml. Binary files and files over 2MB are skipped too. - The root
.gitignoreis honoured: basename patterns, patterns anchored by a slash,*,?,**and a trailing/for directories. A negated (!) line always keeps what it matches scanned, so*.jsonplus!package.jsonstill scanspackage.json. Nested.gitignorefiles are not read. Pass--no-gitignoreto scan ignored files anyway, for example when your ingest key lives in a gitignored file. --ignore <pattern>(repeatable) andignorein the config file add patterns in the same syntax. Ignore patterns keep basename semantics for a name with only a trailing slash (foo/skips afoodirectory at any depth), whilepathson a track function anchors any pattern with a slash,foo/included.
Ingest key detection
A real ingest key is gt_live_ followed by exactly 32 characters of letters,
digits, _ or -. Keys found under test, tests, spec, specs,
fixtures, __tests__, __fixtures__ or __mocks__ directories (any
case), or in *.test.* and *.spec.* files, are counted as test keys. This
is the same test path list the track call scan leaves out. They are listed in the inventory but never
satisfy no_ingest_key and are never used for --report or --live. The
doctor never prints a key.
Quickstart
From your project directory:
npx --yes ravensight-doctorOr scan a specific path:
npx --yes ravensight-doctor path/to/gameOptions:
| Flag | What it does |
| --- | --- |
| --json | Print the report as JSON to stdout, including the inventory and the live result. |
| --report | Also submit the report to the Ravensight API. |
| --live | Send one test event with the ingest key and confirm the API stored it. See The live check. |
| --strict | Exit 1 on any status but pass, including warn and inconclusive, and on any trace finding. |
| --contract <file> | The event contract to check against. Defaults to ravensight.contract.json in the scanned directory when it exists. See The event contract. |
| --trace <file.jsonl> | Replay a file of events against the contract, at most 50,000 lines. See Trace replay. |
| --track-fn <name> | Also count calls to your own track function. Comma separated and repeatable: --track-fn capture,Analytics.logEvent. A bare name matches only unqualified calls; *.capture matches capture on any receiver (quote it in a shell: --track-fn '*.capture'). A Lua method works as telemetry:track. Add @N when the event name is argument N (0 to 9): --track-fn capture@1 for capture(userId, "level_start"). |
| --include-tests | Also look for track calls in test paths. Key checks always cover them. |
| --include-docs | Also look for track calls in Markdown, text and HTML files and in docs and examples directories. |
| --ignore <pattern> | Skip files matching a .gitignore style pattern. Repeatable. |
| --no-gitignore | Scan files the root .gitignore excludes. |
| --bundle <path> | A built .app or an Info.plist: confirm it carries an ingest key, for keys injected at build time. See Keys injected at build time or read from the environment. |
| --key gt_live_... | Ingest key for --report and --live. Falls back to the RAVENSIGHT_INGEST_KEY environment variable, then to the first gt_live_ key found in the scanned project outside test paths, then to the key in the --bundle plist. |
| --api-url <url> | Override the API base URL. Only for self hosting. |
Configuration
Put a ravensight.config.json in the directory you scan to keep settings
with the project:
{
"trackFunctions": [
"Analytics.logEvent",
"telemetry:track",
"*.send@1",
{ "name": "track_event", "argIndex": 1 }
],
"ignoreCalls": ["vendor.capture"],
"forwarders": ["TelemetrySink.send"],
"ingestKey": { "source": "build" },
"kinds": "explicit",
"ignore": ["vendor/", "*.generated.gd"],
"gitignore": true
}trackFunctions: your own track functions. A string is a function name whose first argument is the event name. When the name is a later argument, either add@Nto the string ("capture@1") or use an object withargIndex({ "name": "capture", "argIndex": 1 }); both take 0 to 9 and mean the same thing.--track-fnvalues are added to these.pathson an object entry: glob patterns, relative to the scanned directory, for the files the entry applies to (see below).enricheson an object entry: the prop keys the wrapper adds to every payload before it calls the SDK, like["run_id", "rs_kind"]. See What the static scan proves.forwarders: functions or closure properties that pass the event name on in a way the doctor cannot see. See Forwarding closures.ingestKey:{ "source": "build" }or{ "source": "env" }when the key is not in the source. See below.kinds:"explicit"when every level event carriesrs_kind, which makesname_without_kinda warning;"name"(the default) keeps it an info. See Event names that read as a level event.ignoreCalls: calls to leave out of the inventory entirely, written like atrackFunctionsname: an exact dotted receiver ("vendor.capture") or"*.capture"for any receiver. See "Calls that are not yours to check".ignore: extra patterns to skip, added to any--ignoreflags.gitignore: setfalseto scan files the root.gitignoreexcludes.--no-gitignorealso turns it off.
Invalid JSON or a wrong type on a known key stops the doctor with exit code 2 and a message naming the problem. Unknown keys are ignored, with one note on stderr naming them, so a config written for a later version still works.
Keys injected at build time or read from the environment
The doctor reads source, so a key that is not in the source looks missing. Say where it lives instead:
{ "ingestKey": { "source": "build" } }for a key injected at build time (an .xcconfig expanded into
Info.plist, a Gradle property, a CI secret written into the build). The
no_ingest_key warning becomes the info "Key is injected at build time;
check the built bundle", and --bundle <path> checks a built .app (its
Info.plist, or Contents/Info.plist for macOS) or an Info.plist
directly: bundle_key_present (info) when a gt_live_ key of the right
shape is there, bundle_key_missing (warning) when not, with a hint when
the plist still has unexpanded $(...) settings. An XML plist is read as
text, a binary one through plutil -convert xml1 -o - when plutil is
available and from its raw string bytes otherwise. The key is never
printed; a present key also answers no_ingest_key and is the last
fallback for --live and --report. A --bundle path with no
Info.plist stops the doctor with exit code 2.
{ "ingestKey": { "source": "env" } }for a key the game reads from its environment at startup. no_ingest_key
becomes the info "Key is read from the environment at startup; check the
runtime environment"; run --live with that key in
RAVENSIGHT_INGEST_KEY to confirm it delivers. "source": "source" is the
default.
Calls that are not yours to check
A wrapper often sends the same event name to Ravensight and to another
analytics client, and a *.capture entry in trackFunctions then matches
both calls. Inside a function named in trackFunctions the doctor follows
the wrapper's name parameter on its own, whatever the receiver, and a call
outside the entry's paths that forwards it is forwarded, not
calls_outside_paths. These shapes resolve with nothing to configure:
- The parameter itself, or a local derived from it.
function logEvent(name, props) { vendor.capture(name, props) } - An object literal at any argument position whose
event,nameorevent_nameproperty (or the shorthand) is a parameter of the wrapper, wherever it sits in the wrapper's parameter list, or a derived local.function capture(distinctId, event) { vendor.capture({ distinctId, event, properties }) }is forwarded withcapture@1and*.capture@1, although the object is argument 0. When the call has an argument at the entry's position, only that argument is read, soRavensight.track(currentScreen, { event, ...props })stays unresolved:function logEvent(name, props) { vendor.capture({ event: name, properties: props }) }andconst event = name.trim(); vendor.capture({ event }). The literal may span lines. A string as the value is not forwarded, and neither is an object without one of those three keys. - An object literal with a string name.
vendor.capture({ event: 'level_start' })is a literal name: it is read aslevel_startand counted in the inventory and the event names like any literal first argument (it is not forwarded, and not unresolved). - A field of a message handler's parameter.
window.addEventListener('message', (m) => rs.track(m.name, m.data)) - A forwarding closure's own parameter.
bridge.send = (name, data) => rs.track(name, data); see Forwarding closures. - A queue the wrapper fills and a loop flushes. A member of a loop
variable counts when the same file pushes that key into the iterated
array from inside a wrapper, with the name parameter as the value:
function logEvent(name, props) { queue.push({ event: name, props }) }and, anywhere in the file,for (const call of queue) client.capture(call.event, call.props)(alsocall['event'],queue.forEach((call) => ...),for (const { event } of queue), and an object built from the member,client.capture({ event: call.event })orclient.capture({ event })with a destructuredevent, at the entry's argument position). One level only: JavaScript and TypeScript files, the array matched by its last name (this.queueandqueueare one), and the push must be inside a track function.
These need ignoreCalls, because the doctor cannot follow them:
- A call outside any wrapper.
vendor.capture(name, props)in a function that is not named intrackFunctions. - A queue filled somewhere else. A push from a helper that is not a
track function, a queue built by spread, concat or assignment, or one
emptied with
shift()orpop():const call = queue.shift(); vendor.capture(call.event). - A wrapper with a parameter list over several lines, or a name built
at the call (
vendor.capture('ui_' + id)).
ignoreCalls. A call matching an entry ("vendor.capture" matches that
receiver only; "*.capture" matches any receiver) is left out of the
inventory, counted as ignoredCalls in the JSON and printed as Ignored
calls: N (ignoreCalls), and never produces an unresolved, outside-paths or
contract finding. Use it for a call that sends to another service and never
to Ravensight. An ignored call is not checked at all, so keep the entries
exact.
An unrelated call that matches a track function and is neither forwarded nor ignored stays unresolved.
One function name, different argument positions by path
A project with a server and a browser client can have two functions with
the same name whose event name sits in different places, for example
capture(userId, 'match_finished') on the server and
capture('shop_opened', props) in the dashboard. Give each entry the
paths it applies to, and one scan reads both:
{
"trackFunctions": [
{ "name": "capture", "argIndex": 1, "paths": ["server/**"] },
{ "name": "capture", "argIndex": 0, "paths": ["dashboard/**"] }
]
}pathstakes the same glob patterns asignore, without!, with forward slashes only (a backslash is a config error naming the entry). A leading./is dropped. A pattern that matches a directory covers every file under it.- A pattern with a slash anywhere in it (
src/**,/src/**,src/,server/api) is anchored to the scanned directory:src/**coverssrc/a.jsand neverdashboard/src/lib/telemetry.js, and/src/**means the same assrc/**. - A bare name with no slash (
src,*.mjs) matches a file or directory name at any depth, sosrccovers bothsrc/a.jsanddashboard/src/lib/telemetry.js. Writesrc/**when you mean only the top levelsrcdirectory. - An entry without
pathsapplies everywhere. When several entries share a name, the entry whose matching pattern is the most specific wins for a file (the most characters that are not*,?or/; the earlier entry on a tie), and the entry withoutpathscovers every other file. - Two entries without
pathsfor the same name stop the doctor with exit code 2 and a message naming both, because it could not tell which argument holds the event name. The same goes for two--track-fnvalues. pathslimit where the name is a track function. A call in a file that no entry covers (every entry haspathsand none matches) is not a track call: it is left out of the track calls, the event names and the unresolved count. It is counted incallsOutsidePaths, printed asOutside paths: Nin the pretty inventory, and listed by thecalls_outside_pathswarning (so--strictfails). Add a paths entry that covers those files, or an entry withoutpathsfor the name.- The inventory's
trackFunctionslists each entry on its own, with itspathsand its own call count, and the pretty report shows them ascapture (server/**) 2, capture (dashboard/**) 1. - A
--track-fnvalue never has paths. When the config names the same function with paths only, the flag adds the entry for every other file; when the config already has an entry without paths, the config wins.
Event names that read as a level event
Ravensight's level funnel reads an event's kind from its reserved
rs_kind prop (level_start, level_complete, death or none) and,
when that is missing, from its name: unanchored and case sensitive,
start, begin or enter is a level start, complet, finish, win or
clear a completion, and died, destroyed or killed a death. So
checkout_started, window_opened and door_entered are level events
unless they say otherwise.
name_without_kind is reported at each call whose literal event name
contains one of those words and whose literal props object has no
rs_kind, or that sends no props at all. The fix: add rs_kind, or none
to opt out.
Detection by name is a documented default, not an error, so the finding is
an info and does not change the status. A project that chose to carry
rs_kind on every level event can make it a warning:
{ "kinds": "explicit" }"kinds": "name" is the default.
rs.track('level_start', { level: 3, rs_kind: 'level_start' });
rs.track('checkout_started', { cart: 2, rs_kind: 'none' });A props argument the doctor cannot read (a variable) is not checked, and a
wrapper whose trackFunctions entry lists rs_kind in enriches is
skipped.
The event contract
An optional ravensight.contract.json in the scanned directory (or the
file --contract names) says which events your game must send and what
they must carry. This one is for a narrative game: run checkpoints, choices
and their outcomes paired by decision_id, and unlocks that are earned
once:
{
"events": {
"run_checkpoint": { "required": ["run_id", "chapter", "foreground_seconds"], "join": "run_id" },
"choice_made": { "required": ["decision_id", "depth"], "nonzero": ["depth"] },
"choice_outcome": { "required": ["decision_id", "outcome"], "pairs_with": "choice_made", "join": "decision_id" },
"progress_unlocked": { "required": ["content_id"] }
},
"cumulative": ["foreground_seconds"],
"attempt_ids": ["decision_id"],
"earned_once": { "event": "progress_unlocked", "key": "content_id" }
}events: per event,requiredprops,nonzeroprops (required and never0), andpairs_withwithjoin: an opening event that carries the same value of thejoinprop must appear somewhere in the trace, before or after it.cumulative: props that never go down within the same join id. A plain name follows each event's ownjoin(the session when the event has none);{ "prop": "foreground_seconds", "join": "run_id" }names the join for every event.attempt_ids: props every event in their scope must carry. A plain name applies to the events that name it inrequiredorjoin, and to theirpairs_withopeners;{ "prop": "decision_id", "events": ["..."] }names the scope.earned_once:{ "event", "key" }, or a list of them. The same key may be emitted once per device (device_idin a platform export,deviceIdfrom the SDK), or once per session when the row has no device id. A repeat must carryloaded: true, the marker for unlocks replayed from a save. This is intended: an unlock replayed withloaded: truesets the baseline, and the same key emitted again without the marker after it is flagged, because that is a new unlock event for something already earned.
Invalid JSON, a wrong type, a pairs_with without join or an
attempt_ids name no event uses stops the doctor with exit code 2. Unknown
keys are ignored with a note on stderr.
What the static scan proves
Without --trace the contract is checked against your source only:
- Every event the contract names must appear as a literal event name in a
track call, or it is the warning
contract_event_missing. When none of them appears at all, that is onecontract_unmatchedinfo instead. - A literal props object at a call site (
{ run_id: id }, Lua{ run_id = id }, GDScript{"run_id": id}, Swift["run_id": id]) that lacks a required key is the warningcontract_prop_missing, with the file and line. A props argument the doctor cannot read for certain (a variable, a spread, a computed key) is not checked. - A call through your own track function is different: the wrapper may
merge keys into every payload before it calls the SDK
(
emit("resource_changed", run, ["resource_id": id, "amount": delta])addingrun_id,rs_kindand build counters). Source cannot show that, so a missing key there is the infocontract_prop_unproven, "not provable statically; the runtime trace is the evidence", never a warning. List the keys the wrapper adds in its entry'senriches({ "name": "*.emit", "enriches": ["run_id", "rs_kind"] }) and they are never reported missing at its call sites; once an entry declaresenriches, a required key it does not list iscontract_prop_missingagain, since the declaration says what the wrapper adds.enricheschanges the static check only: the trace replay stays strict and checks every required key on every event.
This proves presence in source, not runtime values. Static scanning cannot
prove that pairs line up, that a duration never goes down, that an attempt
id is filled in, that a depth is not zero, or that an unlock is not
repeated after loading a save. Those are checked only by trace replay,
over a file you provide. The pretty report prints a Contract block saying
so, and the JSON has contract: { file, events, matched, missing, propSitesChecked, propsUnproven }.
For the contract above and this source, the doctor reports
contract_event_missing for progress_unlocked and
contract_prop_missing for depth at src/story.js:6:
export function checkpoint(run, chapter) {
rs.track('run_checkpoint', { run_id: run.id, chapter, foreground_seconds: run.seconds });
}
export function choose(decision) {
rs.track('choice_made', { decision_id: decision.id });
rs.track('choice_outcome', { decision_id: decision.id, outcome: decision.result });
}Trace replay
--trace <file.jsonl> replays a file of events against the contract. It
is a runtime check over the file you provided, not a proof of production
behaviour. Each line is one event, either the shape an exported session
and get_integration_status give:
{ "event_name": "choice_made", "ts": "2026-10-14T10:01:00Z", "session_id": "s1", "props": { "decision_id": "d1", "depth": 2 } }or the SDK's batch shape:
{ "event": "choice_made", "data": { "decision_id": "d1", "depth": 2 }, "timestamp": "2026-10-14T10:01:00Z", "deviceId": "dev-1" }The SDK sends the session id as a request header, not in the body, so a
batch shape event has no session unless the line (or its container)
carries session_id; add it when you capture several sessions into one
file. A line holding an events array (a captured request body or a page
of an export) is expanded into its events, and a session_id or
device_id on the container applies to each of them.
At most 50,000 lines are read; the rest of the file is ignored and the
report says so. The file is read line by line and each event keeps only
the props the contract names, so memory follows the number of events, not
the file size. A line over 64 KB is skipped without being read. Blank
lines are ignored; any other line that gives no event (not JSON, not an
object, no event_name or event, over 64 KB) is skipped, and the first
10 skipped lines are listed with their line numbers and the reason. When
no event at all could be checked (an empty file, or nothing readable) the
replay reports the warning trace_nothing_checked.
Events are compared in file order. When you combine files from several
devices or sessions, sort the result by ts first, so a cumulative value
is compared in the order it was sent. --trace needs a contract; without
one it stops with exit code 2.
| Finding | What it means |
| --- | --- |
| trace_cumulative_decreased | A cumulative prop went down within the same join id. |
| trace_orphan_outcome | A pairs_with event arrived for a join id whose opening event appears nowhere in the trace. |
| trace_missing_attempt_id | An event in an attempt_ids scope arrived without the id. |
| trace_missing_depth | A required prop was absent or empty, or 0 where the contract marks it nonzero. |
| trace_repeated_earned | The same earned_once key was emitted twice on one device (or in one session, when the row has no device id) without loaded: true. |
| trace_nothing_checked | The file gave no event to check: it is empty, or no line is a readable event. |
Each is a warning that lists its first 10 offending rows (file line, event,
session and what was wrong) and counts the rest. The findings are printed
in a block titled "Trace replay (runtime check over the file you provided,
not a proof of production behaviour)" and in the JSON as
trace: { file, lines, checked, skipped, skippedLines, truncated, maxLines, findings },
where skippedLines lists the first 10 skipped lines as { line, reason }.
They do not change the status and are never sent by --report;
--strict exits 1 on any of them.
For the narrative contract, this trace:
{"event_name":"run_checkpoint","session_id":"s1","props":{"run_id":"r1","chapter":1,"foreground_seconds":120}}
{"event_name":"choice_made","session_id":"s1","props":{"decision_id":"d1","depth":2}}
{"event_name":"choice_outcome","session_id":"s1","props":{"decision_id":"d1","outcome":"spared"}}
{"event_name":"choice_outcome","session_id":"s1","props":{"decision_id":"d9","outcome":"fled"}}
{"event_name":"choice_made","session_id":"s1","props":{"depth":0}}
{"event_name":"progress_unlocked","session_id":"s1","props":{"content_id":"ending_a"}}
{"event":"run_checkpoint","session_id":"s2","data":{"run_id":"r1","chapter":2,"foreground_seconds":45}}
{"event":"progress_unlocked","session_id":"s2","data":{"content_id":"ending_a","loaded":true}}
{"event":"progress_unlocked","session_id":"s2","data":{"content_id":"ending_a"}}gives one row for each check: foreground_seconds went from 120 to 45
within run_id=r1 (line 7, a timer reset on load), decision_id=d9 never
had a choice_made (line 4), the choice_made on line 5 has no
decision_id and a depth of 0, and content_id=ending_a was emitted
again on line 9 after the loaded baseline on line 8.
The live check
--live proves the key in your project can deliver an event, end to end:
GET /api/v1/settingswith the ingest key. A401means the key is wrong; tracking switched off for the game stops the check. The answer names the game the key belongs to, printed askey belongs to <name> (<slug>, <environment>).POST /api/v1/sessionwith platformdoctor.POST /api/v1/track/batchwith oneravensight_doctor_pingevent whose data isrs_kind: "none", a randomrs_doctorvalue and the doctor version.GET /api/v1/doctor/ping/<rs_doctor value>every 2 seconds, at most 10 times, until the server confirms it stored the event.
Steps 1 to 3 are each tried once more, after 3 seconds, when the first answer
is a 5xx or no answer at all (an API restart or a network blip). A 4xx is
never retried. When a step was retried the note says so (for example
GET /api/v1/settings retried once after 502) and the JSON lists the steps in
live.retried; only a second failure of the same step fails the check. Step 4
is already a poll, so a single bad answer there does not stop it.
The server keeps doctor sessions out of your analytics and your event quota,
and allows them only that one event name. The result is printed in a Live
check block and in the JSON as
live: { ok, stage, trackingEnabled, accepted, found, acceptedAt, event, note, game },
plus retried when a step was tried twice. game is
{ slug, name, environment }, or null from a server that does not say.
The JSON keeps the game values as the server sent them (a note from a
server message is sanitised in both outputs); the printed line, like every
server message and every finding the doctor prints, has escape sequences,
control, bidi and zero width characters removed and is cut at 80
characters. The Inventory's event names stay verbatim.
Which environment a key hits. Project and game ids are a release audit
check, not initialization inputs: the SDK starts with the ingest key, and
the key alone decides which game and environment receive the events. The
key belongs to <name> (<slug>, <environment>) line is the free way to
confirm which environment a key hits before a release, since doctor
sessions stay out of your analytics and your event quota. Run it with the
key from the build you are about to ship (--key, RAVENSIGHT_INGEST_KEY
or --bundle).
| Outcome | Result |
| --- | --- |
| Read back | stored, with the time the server accepted it. |
| Accepted but not read back within 20 seconds | The warning live_ping_unconfirmed. Exit 0, or 1 with --strict. |
| Bad key, tracking switched off, a refused session or event, or no network (after the one retry for a 5xx or network error) | The error live_check_failed, so the status is fail (including in a --report sent afterwards), and exit 1. The note says which step failed. |
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | pass, warn or inconclusive, with no --strict. With --strict, only pass. |
| 1 | At least one error finding, a --report submission failure, a --live check that could not deliver its event (live_check_failed), or with --strict any status but pass. The local report is always printed first. |
| 2 | Usage error: unknown flag, missing value, a path that is not a directory, an invalid --track-fn (including an @N above 9), a ravensight.config.json with invalid JSON, a wrong type, invalid paths, or two entries without paths for the same function, a broken contract or a missing --contract file, a --trace file that cannot be read or has no contract to check against, an invalid enriches, forwarders, ingestKey or kinds, or a --bundle path with no Info.plist. |
How each status maps to an exit code:
| Status | Without --strict | With --strict |
| --- | --- | --- |
| pass | 0 | 0 |
| warn | 0 | 1 |
| inconclusive (no literal names, or 25 percent or more unresolved) | 0 | 1 |
| fail | 1 | 1 |
Trace findings do not change the status, so they never set the exit code
on their own; with --strict any trace finding exits 1.
Errors fail CI builds. Warnings and inconclusive do not, on purpose, so you
can adopt the doctor without breaking every pipeline on day one. Add
--strict when you want them to.
CI setup
GitHub Actions
The repository ships a composite action, so one step is enough:
jobs:
doctor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Reality-Software-Entertainment/ravensight-doctor@v0With report submission:
- uses: Reality-Software-Entertainment/ravensight-doctor@v0
with:
path: .
report: "true"
api-key: ${{ secrets.RAVENSIGHT_INGEST_KEY }}Inputs: path (default .), report (default "false"), live
(default "false"), strict (default "false"), track-fn (comma
separated function names, default empty), include-tests and
include-docs (default "false"), contract (a contract file,
default empty, which uses ravensight.contract.json in path when it
exists), trace (a JSONL file of events to replay, default empty) and
api-key (used when report or live is "true"; store it as a
repository secret). Paths are relative to the workspace.
GitLab CI
ravensight-doctor:
image: node:22
stage: test
script:
- npx --yes ravensight-doctor .See docs/gitlab-ci.md for report submission and artifact variants.
The report API
--report sends the report to your Ravensight dashboard:
POST {api-url}/api/v1/doctor
X-API-Key: gt_live_...
Content-Type: application/json{
"engine": "godot",
"doctorVersion": "0.2.0",
"summary": { "status": "warn", "errors": 0, "warnings": 1 },
"findings": [
{
"id": "event_name_cardinality",
"severity": "warning",
"title": "Event name looks high cardinality",
"detail": "...",
"file": "scripts/player.gd",
"line": 7
}
]
}status is fail when any error finding exists, warn when any warning
does, otherwise pass. The API knows these three only, so an
inconclusive report is sent as warn, and the inventory, live result,
contract block and trace replay stay on your machine (the static
contract_* findings are sent like any other finding). At most 100 findings are included; past that they are
truncated and an info finding says so. A 201 means stored. Anything else
(bad key, bad payload, network trouble) is reported with a clear message and
the command exits 1, with the local report still printed.
The gt_live_... ingest key used here is the same publishable key your game
ships with. It cannot read analytics or touch your account.
What it never does
- It never prints a secret it finds. Only a masked prefix (first 8 characters) appears in findings.
- It never uploads your source code.
--reportsends only the findings shown above. - It never sends a
--tracefile anywhere; the replay runs on your machine. - It never phones home unless you pass
--reportor--live.--livesends one test event and nothing from your source.
Development
npm testThe tests use the built in node:test runner over fixture projects in
test/fixtures/. Every credential in the fixtures is obviously fake and
planted on purpose.
License
MIT. Copyright 2026 Reality Software Entertainment.
