Claude Code with RudderStack: Event Streaming the Right Way
Why RudderStack without CLAUDE.md ships event chaos
RudderStack is an event streaming platform that sits between your app and the dozen-plus downstream destinations that need product analytics, marketing analytics, warehouse loads, and email triggers. The pitch is: instrument once, route everywhere. The payoff only lands if the events you send conform to a stable tracking plan. Conform poorly and the downstream destinations each receive a different version of reality.
Claude Code does not know your tracking plan unless you tell it. Without a CLAUDE.md in place, Claude invents event names ad-hoc. It uses track where identify belongs. It nests properties three deep when warehouse loads expect flat columns. It calls the client-side SDK from the server, or the server-side SDK from the client. It puts the write key in client-side code without scoping it as a "client" write key, which is a credential leak.
This guide gives you the CLAUDE.md configuration that anchors Claude Code to how RudderStack actually works: tracking plan as the source of truth, identify and track and group used correctly, transformations for destination-specific reshaping, and the right source for the right environment. If you are comparing analytics platforms, Claude Code with PostHog covers the equivalent setup for self-hosted product analytics.
The RudderStack CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a RudderStack project it needs to declare: the tracking plan location, the API spec for identify, track, group, and page calls, the destination map, the transformation patterns, and the write key separation.
# RudderStack rules
## Stack
- RudderStack 1.x (Cloud or self-hosted)
- @rudderstack/analytics-js (browser), @rudderstack/rudder-sdk-node (server)
- Tracking plan in /tracking-plan/events.json (single source of truth)
- Destinations configured in RudderStack control plane, never in app code
## Tracking plan rules (STRICT)
- ALWAYS update /tracking-plan/events.json BEFORE adding a new event
- Every event has: name, description, owner team, required properties, optional properties
- Property names use snake_case for warehouse compatibility
- Property values are flat , no nested objects unless destination supports them
- Currency amounts are integers in minor units (cents, pence), never floats
## API call selection
- identify , when a user logs in, signs up, or attributes change
- track , when a user does a thing (purchase, click, view)
- group , when a user is associated with an account or organisation
- page , automatic in browser, never call manually
- screen , mobile equivalent of page
- alias , when an anonymous user becomes identified , call ONCE per merge
## Identify versus track , the rule
- identify carries user traits (email, name, plan, signup_date)
- track carries event properties (order_id, value, currency, items)
- NEVER put user traits in track event properties , put them in identify
- NEVER track 'User Updated Profile' instead of calling identify with new traits
## Property naming (STRICT, warehouse-driven)
- snake_case for properties
- Reserved names: id, timestamp, anonymous_id, user_id, event, properties, context
- Currency fields: amount (integer minor units), currency (ISO 4217)
- Timestamp fields: ISO 8601 strings, never epoch numbers
## Source selection (CRITICAL for security)
- Browser source , write key is public, scope events accordingly
- Server source , write key is private, use for sensitive events
- NEVER send PII to a browser source , send via server source
- NEVER hardcode the server write key in client code
## Destination strategy
- Destinations configured via control plane, never in code
- Use transformations for destination-specific reshaping
- Use event filters in the destination config to control which events flow
- ALWAYS test new destinations with a sandbox source first
## Transformation rules
- One transformation per destination unless logic is genuinely shared
- Transformations are JavaScript functions , async/await supported
- ALWAYS return the event or null (null drops the event)
- ALWAYS handle the .traits and .properties shape differences across call types
## GDPR / privacy
- User deletion uses the suppression API, not in-app overrides
- Implement Do Not Track via the consent context property
- Document the per-destination retention in the tracking plan
Drop this into CLAUDE.md, point Claude at your /tracking-plan/events.json, and let it generate against the plan. The plan-first rule is the load-bearing piece.
Tracking plan as the source of truth
The tracking plan is the schema for your event data. It lists every event your app emits, the properties each event carries, the required versus optional split, and the owner team. RudderStack supports tracking plans as a first-class concept; you can attach one to a source and have RudderStack reject or warn on events that violate the plan. The plan-as-code version, stored in your repo, is the version Claude reads.
The rule "ALWAYS update /tracking-plan/events.json BEFORE adding a new event" stops Claude from inventing event names mid-feature. Without it, the same checkout flow ends up emitting Checkout Started, checkoutInitiated, start_checkout, and CheckoutBegan across four PRs by four different humans collaborating with Claude. Each warehouse table has slightly different columns because each variant arrives slightly differently. The fix is upstream of the events: enforce the name and the property shape before the event ever fires.
A useful tracking plan entry looks like this:
{
"name": "Order Completed",
"description": "Emitted when a checkout completes successfully",
"owner": "checkout-team",
"required": ["order_id", "amount", "currency"],
"optional": ["coupon", "shipping", "tax", "items"],
"properties": {
"order_id": { "type": "string", "format": "uuid" },
"amount": { "type": "integer", "description": "Minor units (pence)" },
"currency": { "type": "string", "format": "iso-4217" },
"items": { "type": "array", "items": { "$ref": "#/definitions/LineItem" } }
}
}
Claude generates the track call against this plan. The required properties are not optional. The types match. The order_id is a UUID string, not a database integer. Warehouse loads pick this up without per-event manual reshaping.
Identify versus track, and why mixing them breaks reporting
The most common RudderStack mistake Claude makes without instruction is putting user traits into track event properties. track('User Logged In', { email, plan, signup_date }) looks reasonable. It is wrong. The email, plan, and signup_date are user traits, not event properties. They belong in identify, not track.
The consequence shows up in the warehouse. A users table built from identify calls has stable, current traits per user. A track_events table with traits sprinkled across event properties has stale snapshots scattered across rows. Reporting that joins user traits to events expects the first shape and breaks against the second.
The CLAUDE.md rule "NEVER put user traits in track event properties" plus "NEVER track 'User Updated Profile' instead of calling identify with new traits" addresses the two halves of this. The first half stops the sprinkling. The second half stops the redundant track event that mirrors what identify already conveys.
Get Claudify. Skills, hooks, and CLAUDE.md templates that make Claude Code wire RudderStack correctly the first time.
Source selection, write keys, and the PII boundary
RudderStack sources come in two flavours: web/mobile (public write key, designed for the client) and server (private write key, designed for the backend). The two have different security postures and different appropriate use cases.
PII should flow through the server source. Email addresses, IP addresses, account identifiers, anything subject to GDPR. The client source emits whatever the browser can see, which is fine for clicks and page views but is not the right channel for email or phone number captures. The CLAUDE.md rule "NEVER send PII to a browser source" is the line that prevents the common pattern of "identify the user from the signup form on the client."
The correct shape is: client-side track captures the signup event with an anonymous_id. The server, on receipt of the form submission, calls identify with the user_id and traits via the server source. RudderStack's alias call then merges the anonymous_id with the user_id once. Claude generates this two-step pattern when the CLAUDE.md flags the PII boundary; without the flag, Claude reaches for the simpler one-step client identify, which leaks PII through the browser.
Transformations and the destination-shape problem
Different destinations expect different shapes. Mixpanel wants $distinct_id and properties.$insert_id. Amplitude wants user_id and insert_id. Postgres wants flat columns. S3 wants the raw event. RudderStack transformations are the layer that reshapes the event per destination without changing the event you emitted.
A transformation looks like this:
export async function transformEvent(event, metadata) {
if (event.type !== 'track') return event;
if (event.event === 'Order Completed') {
event.properties.revenue = event.properties.amount / 100;
event.properties.currency = event.properties.currency.toLowerCase();
}
return event;
}
The rule "ALWAYS return the event or null" matters. Claude defaults to silently mutating and forgetting to return, which drops the event entirely because RudderStack interprets undefined as "drop." The rule "One transformation per destination unless logic is genuinely shared" prevents the other failure mode: one giant transformation that handles every destination, where a bug in one branch breaks unrelated event flows.
| Concern | Where it lives | Why |
|---|---|---|
| Event name and properties | tracking-plan/events.json | Source of truth, schema enforced |
| Destination routing | Control plane config | Operations layer, not code |
| Destination reshaping | Transformations | Per-destination logic, isolated |
| PII redaction | Server source filter | Privacy boundary at the source |
| User suppression | Suppression API | GDPR compliance, audited |
Reliability, retries, and the queue behaviour
RudderStack SDKs queue events locally and flush them in batches. The browser SDK uses localStorage; the server SDK uses an in-memory queue with optional persistence. If the network is down, events queue. If the destination is down, RudderStack queues server-side and retries. Claude needs to know this so it stops adding redundant retry logic on top.
The wrong pattern is wrapping every track call in a try/catch with custom retry logic. The right pattern is calling track and letting the SDK handle delivery. The server SDK exposes a callback parameter that fires on delivery (or failure), which is the integration point if you need to know about delivery state. The CLAUDE.md should flag this so Claude does not invent a retry layer that fights the SDK's retry layer.
For the server side, the rule "always call rudderClient.flush() before process exit" is the line that prevents the most common drop in serverless environments. Lambda functions that exit before the queue flushes lose events. Adding the flush to the shutdown handler is one line that Claude misses without the rule in CLAUDE.md.
GDPR, suppression, and the right deletion path
User deletion requests in a RudderStack-instrumented app have two layers. The suppression API tells RudderStack to drop future events for a given user_id. Destination-specific deletion happens in each destination separately, because RudderStack does not have universal write-back-and-delete privileges across every downstream tool.
The CLAUDE.md rule "User deletion uses the suppression API, not in-app overrides" stops Claude from inventing a fake user_id at deletion time, which appears to solve the problem and instead just splits the user's data across two IDs. The suppression API is the correct call, followed by per-destination deletion in the platforms that have it (Mixpanel, Amplitude, Segment, your warehouse).
For broader hook patterns that automate the verification of these flows, Claude Code skills covers how to package the loop. Claude Code best practices covers the CLAUDE.md structure that makes the rules above stick across sessions.
Verifying Claude's RudderStack integration before it ships
A CLAUDE.md is necessary but not sufficient. The verification loop runs against the RudderStack debug console plus the destination dashboards. The check is:
- Generate the instrumentation with Claude
- Trigger the event in a sandbox source
- Inspect the RudderStack live events stream for shape and properties
- Inspect each destination dashboard for arrival and reshape correctness
- Run the tracking plan validator against the JSON spec to catch drift
A hook that pings the live events endpoint after every change and surfaces the most recent event back to Claude tightens the loop. Claude then iterates against actual delivered shapes rather than against an assumed shape.
Wrapping up
RudderStack rewards discipline. A tracking plan that everyone reads. Identify and track and group used for their actual purposes. Source selection that respects the PII boundary. Transformations that reshape per destination without leaking logic. Suppression handled via the API rather than in-app workarounds.
Claude Code without a CLAUDE.md invents event names, mixes user traits into event properties, sends PII through the browser, and writes retry logic that fights the SDK. With the template above, Claude Code wires RudderStack the way the platform was designed to be wired, and the downstream destinations each receive a consistent, correctly-shaped view of the same events.
Get Claudify. Skills, hooks, and CLAUDE.md templates that make Claude Code wire RudderStack correctly the first time.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify