Instrumentation Workflow with Generated Clients Beta
- free
- growth
- enterprise
9 minute read
This guide describes a workflow built on RudderTyper v2 and Local Generation, both in Public Beta.
Generating a typed client is one command. Running that client as the contract for a real application — across two repositories, a team, and a review process — is a workflow. This guide describes the one RudderStack uses internally to instrument its own web application, and the specific failure modes that shaped it.
The shape of it
Two repositories, in a fixed relationship:
catalog repository application repository
────────────────── ──────────────────────
data-catalog/
events/ ──┐
properties/ │ rudder-cli src/analytics/generated/ (generated,
custom-types/ ├─ typer ──▶ index.ts committed,
tracking-plans/ ──┘ generate SOURCE.md never edited)
app.yaml --local
src/analytics/client.ts (written once)
src/** (call sites)The Tracking Plan is the source of truth. The client is a build artifact of it. Application code consumes the artifact and never defines an event shape of its own.
This has one consequence worth stating plainly: you cannot add a new event from the application repository alone. An application pull request that invents an event without a matching catalog change is incomplete by construction.
If your Tracking Plan lives in the same repository as your application, everything below still applies, but the merge-order and provenance sections collapse into “both sides land in one commit”. That is the simpler setup — prefer it when you have only one consumer.
The loop
Steps 1 to 4 are entirely local. Generation publishes nothing and touches no workspace, so iterate freely until the call sites need no adapters. Only then open pull requests.
- Edit the catalog. Define the event, its properties, and the Tracking Plan rule binding them, branched off the catalog’s current default branch.
- Regenerate. Run your sync script. The types update straight from those local edits.
- Add the call sites and compile. The generated types are now the contract, and the compiler lists every call site that does not satisfy it.
- If the types fight you, go back to step 1. Reshape the catalog. Do not reshape the call site.
- Verify in the running application. Fire the event and confirm the payload.
Never add a type cast, a?? '', or a sentinel value such as'none'to make a payload satisfy a generated type. Doing so does not solve a problem; it suppresses a finding.
Why casting is the wrong instinct
When RudderStack migrated its own web application onto a generated client, the application immediately failed to compile. The stricter types were not the problem — they were the first thing in years to notice a real one.
Two identify traits were marked required: true in the Tracking Plan and were absent on roughly 40% of production identify calls. The cause was a hydration race: the application fired identify on login before its billing store had loaded, then fired it again once the store hydrated. 94% of users had both populated and empty identify calls.
The previous generator had leaked | undefined into required fields, so the mismatch compiled and shipped for years. Every layer that could have caught it was absent or broken. The analyses built on those traits were quietly wrong: a funnel segmented by plan at event time silently dropped or mis-bucketed about 40% of its rows.
The fix was to correct the contract — one trait was genuinely optional and was remodeled as such; the other needed an application fix. A cast at the call site would have restored the compile and preserved the bug.
A type error from a generated client is usually a finding about the Tracking Plan, not an obstacle. If a property is not always available, mark it required: false and let it be optional in the generated type.
Constructing the client
For TypeScript, construct the client exactly once, in one module, and export that instance.
// src/analytics/client.ts
import type { RudderAnalytics } from '@rudderstack/analytics-js';
import { RudderTyper } from './generated';
export const analytics = new RudderTyper(() => window.rudderanalytics as RudderAnalytics);The constructor takes a resolver function, not an SDK instance, and this is not a style preference.
The standard JavaScript SDK snippet installs a buffering preloader on window.rudderanalytics immediately, then replaces it with the real SDK once the script loads asynchronously. A client constructed at import time — before that swap — that captured the instance would hold the preloader forever. Every event fired after the SDK loaded would go into an abandoned queue. No error, no type error, no failed build. Events simply stop arriving.
Because the resolver is re-invoked on every call, the swap is picked up automatically. From Rudder CLI v0.22.0 the constructor is resolver-only, so the unsafe form does not compile:
new RudderTyper(window.rudderanalytics);
// error TS2345: Argument of type 'RudderAnalytics' is not assignable
// to parameter of type '() => RudderAnalytics'.
Kotlin and Swift clients take an SDK instance rather than a resolver, because mobile applications construct the SDK themselves and there is no asynchronous swap to survive. Do not carry the resolver pattern across platforms.
Two more rules for the construction point:
- Handle an absent SDK. The generated code calls
analytics.track(...)without guarding, so when no write key is configured andwindow.rudderanalyticsis undefined, firing an event throws. Substitute a no-op stub in your resolver. - Never let analytics change behavior. Wrap
trackcalls intry {} catch {}whenever they sit inside atrywhosecatchshows the user an error. Otherwise a blocked or missing SDK turns a successful user action into a reported failure. For the same reason, never fetch data to enrich a payload — use what the page already loaded.
Provenance
Because the types come from specs on disk, a stale or wrong-branch catalog checkout produces a different, valid-looking client that compiles and passes tests. This is the easiest way to ship a wrong client, and the catalog path usually defaults to a directory that exists and is often on some other branch.
Have your sync script write a SOURCE.md next to the generated client, recording the catalog commit, the branch, and whether the working tree was clean. Commit it alongside index.ts, and check it before committing:
- Does the commit match the catalog change you actually made? Good.
- Does it say the working tree was dirty? The client is not reproducible from any commit — commit the catalog first, then regenerate.
- Anything else? You generated from the wrong checkout. Regenerate.
When reviewing either pull request, check that SOURCE.md points at the paired catalog change. It is the only thing tying the two repositories together.
Merge order
The two pull requests are ordered, not simultaneous:
- Open the catalog pull request.
- Open the application pull request — regenerated client,
SOURCE.md, and call sites — cross-linked to the catalog one. - Merge the catalog pull request to its default branch first.
- Rebase the application pull request, regenerate against the catalog’s default branch, then merge.
The reason is mechanical: the committed client must be reproducible by regenerating off the catalog’s default branch. Merging the application side first makes a regeneration off that branch produce a different client, one missing the new event. The committed client drifts, and its recorded provenance becomes false.
Keep the catalog pull request rebased on its default branch while it is open. If other events land meanwhile, regenerating from the stale branch silently drops them.
Automate the check
Everything above is a convention, and conventions hold exactly as long as every author and reviewer remembers them. One CI job removes that dependency.
Add a --check mode to your sync script that regenerates into a temporary directory and diffs against the committed client:
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
RUDDERSTACK_CLI_EXPERIMENTAL=true RUDDERSTACK_X_LOCAL_TYPER=true \
rudder-cli typer generate --local --location "$CATALOG_PATH" \
--tracking-plan-id storefront --platform typescript \
--output "$tmp" --option outputFileName=index.ts
diff -u src/analytics/generated/index.ts "$tmp/index.ts" \
|| { echo "run the sync script and commit the result" >&2; exit 1; }Then run it on every pull request, against a checkout of the catalog’s default branch:
name: Typed client drift
on:
pull_request:
paths: ['src/analytics/generated/**', 'scripts/tp-sync.sh']
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: your-org/your-catalog-repo
path: .catalog
token: ${{ secrets.CATALOG_READ_TOKEN }}
- name: Install rudder-cli
env:
RUDDER_CLI_VERSION: 0.24.0
run: |
curl -fsSL "https://github.com/rudderlabs/rudder-iac/releases/download/v${RUDDER_CLI_VERSION}/rudder-cli_Linux_x86_64.tar.gz" \
| tar -xz -C /usr/local/bin rudder-cli
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- run: npm ci
- env:
CATALOG_PATH: .catalog
run: npm run tp:checkThis job catches a call-site pull request merged without regenerating, a client generated from a wrong-branch or dirty checkout, and a catalog change merged without its consumer being regenerated. It also enforces the merge order for free. Because it asks whether the committed client is reproducible from the catalog’s default branch, an application pull request that lands ahead of its catalog change cannot pass.
Pin the Rudder CLI version in this job. The generated file’s header embeds the CLI version that produced it, so an unpinned CLI turns every release into a spurious diff. Bump the pin in the same pull request that commits a client regenerated with the newer CLI.
Two other details keep the job honest. Check out the catalog’s default branch rather than a ref derived from the pull request, and do not run rudder-cli apply from it. Applying mutates a workspace, and a pull request check must not. Applying belongs in a deploy job on the catalog repository, after merge.
Generating at build time instead
You can generate the client during the build rather than committing it. RudderStack does not recommend it:
- It puts the CLI and a catalog checkout on the critical path of every build.
- It hides catalog changes from code review. The diff in the generated client is the most reviewable artifact in the whole workflow — it shows exactly which event shapes changed, in the same pull request as the call sites that use them.
- It makes “which specs produced this client?” unanswerable after the fact.
Commit the client, and let CI prove it is current.
Review checklist
For the application pull request:
- Does the diff include the regenerated client? If not, the generator was not run.
- Does
SOURCE.mdpoint at the paired catalog change, on a clean tree? - Are there any casts,
?? '', or sentinel values around a typed call? Each one is a catalog bug in disguise. - Is the client constructed in exactly one place, with a resolver?
- Are
trackcalls inside error-handling paths wrapped so they cannot fail the action? - Is the catalog pull request linked, and does it merge first?
Related documentation
- Generate Bindings from Local Specs: Generating without a workspace
- RudderTyper v2 Command Reference: Complete flag and option list
- RudderTyper v2 Walkthrough Guide: Generating and installing bindings for the first time
- Tracking Plans and Data Catalog: The YAML schema the catalog uses