Theme 04 · Debuggability-Focused
Safe telemetry
Explanation
Safe telemetry
Plain Human Explanation
Telemetry should help the team understand product behavior without collecting more about users than the product needs. The goal is to answer questions like “how often does onboarding finish?” without storing names, emails, notes, tokens, or raw form text.
Safe telemetry is a product decision as much as an engineering one. Every event says what the company believes is worth measuring.
Technical Explanation
Define an allowlist of event names and properties. Avoid raw payloads, free-text fields, authentication data, and values that can quietly become personal profiles.
In TypeScript, this often means a small TelemetryEvent union and an Analytics port that accepts only those event shapes. The compiler then prevents casual calls like track("signup", req.body).
Why It Matters
- User impact: people can use the product without private details leaking into analytics tools.
- Product behavior: metrics stay stable because events have clear names and documented fields.
- Risk: raw telemetry can create privacy, retention, and access-control problems outside the main database.
- Decision point: add telemetry when the question is clear and the safe fields are enough to answer it.
The Core Move
Record useful product facts with an allowlist. If a field is not needed for a real metric or debugging decision, leave it out.
Small Example
Safe telemetry: Small Example
Bad TypeScript Example
type Analytics = {
track(event: string, properties: Record<string, unknown>): void;
};
export function trackProfileSaved(body: Record<string, unknown>, analytics: Analytics) {
analytics.track("profile_saved", body);
}
type Analytics = {
track(
event: string,
properties: Record<string, unknown>,
): void;
};
export function trackProfileSaved(
body: Record<string, unknown>,
analytics: Analytics,
) {
analytics.track("profile_saved", body);
}
Good TypeScript Example
type ProfileEvent =
| {
name: "profile.saved";
properties: {
changedAvatar: boolean;
changedTimezone: boolean;
};
}
| {
name: "profile.save_failed";
properties: {
reason: "validation" | "storage";
};
};
type Analytics = {
track(event: ProfileEvent): void;
};
export function trackProfileSaved(
changes: {
avatarUrlChanged: boolean;
timezoneChanged: boolean;
},
analytics: Analytics,
) {
analytics.track({
name: "profile.saved",
properties: {
changedAvatar: changes.avatarUrlChanged,
changedTimezone: changes.timezoneChanged,
},
});
}
type ProfileEvent =
| {
name: "profile.saved";
properties: {
changedAvatar: boolean;
changedTimezone: boolean;
};
}
| {
name: "profile.save_failed";
properties: {
reason: "validation" | "storage";
};
};
type Analytics = {
track(event: ProfileEvent): void;
};
export function trackProfileSaved(
changes: {
avatarUrlChanged: boolean;
timezoneChanged: boolean;
},
analytics: Analytics,
) {
analytics.track({
name: "profile.saved",
properties: {
changedAvatar:
changes.avatarUrlChanged,
changedTimezone:
changes.timezoneChanged,
},
});
}
What Changed
- The bad version sends the whole profile payload to analytics.
- The good version allows only named events and safe product facts.
- The event answers a clear product question without copying personal profile fields.
Realistic Example
Safe telemetry: Realistic Example
This example instruments onboarding. The team wants to know where users drop off, but not what each user typed into profile fields.
Bad TypeScript Example
export async function saveOnboardingStep(req: any, services: any) {
await services.profiles.save(req.user.id, req.body);
services.analytics.track("onboarding_step_saved", {
user: req.user,
body: req.body,
headers: req.headers,
});
}
export async function saveOnboardingStep(
req: any,
services: any,
) {
await services.profiles.save(
req.user.id,
req.body,
);
services.analytics.track(
"onboarding_step_saved",
{
user: req.user,
body: req.body,
headers: req.headers,
},
);
}
Good TypeScript Example
type OnboardingStep = "account" | "workspace" | "invite-team";
type OnboardingInput = {
userId: string;
step: OnboardingStep;
invitedCount: number;
};
type TelemetryEvent =
| {
name: "onboarding.step_completed";
properties: {
step: OnboardingStep;
invitedRange: "0" | "1-3" | "4+";
};
}
| {
name: "onboarding.step_failed";
properties: {
step: OnboardingStep;
reason: "validation" | "storage";
};
};
type ProfileStore = {
saveOnboarding(input: OnboardingInput): Promise<void>;
};
type Analytics = {
track(event: TelemetryEvent): void;
};
function invitedRange(count: number): "0" | "1-3" | "4+" {
if (count === 0) return "0";
if (count <= 3) return "1-3";
return "4+";
}
export async function saveOnboardingStep(input: OnboardingInput, store: ProfileStore, analytics: Analytics) {
await store.saveOnboarding(input);
analytics.track({
name: "onboarding.step_completed",
properties: {
step: input.step,
invitedRange: invitedRange(input.invitedCount),
},
});
}
type OnboardingStep =
| "account"
| "workspace"
| "invite-team";
type OnboardingInput = {
userId: string;
step: OnboardingStep;
invitedCount: number;
};
type TelemetryEvent =
| {
name: "onboarding.step_completed";
properties: {
step: OnboardingStep;
invitedRange: "0" | "1-3" | "4+";
};
}
| {
name: "onboarding.step_failed";
properties: {
step: OnboardingStep;
reason: "validation" | "storage";
};
};
type ProfileStore = {
saveOnboarding(
input: OnboardingInput,
): Promise<void>;
};
type Analytics = {
track(event: TelemetryEvent): void;
};
function invitedRange(
count: number,
): "0" | "1-3" | "4+" {
if (count === 0) return "0";
if (count <= 3) return "1-3";
return "4+";
}
export async function saveOnboardingStep(
input: OnboardingInput,
store: ProfileStore,
analytics: Analytics,
) {
await store.saveOnboarding(input);
analytics.track({
name: "onboarding.step_completed",
properties: {
step: input.step,
invitedRange: invitedRange(
input.invitedCount,
),
},
});
}
What Changed
- The bad version ships user, request, and form data into analytics.
- The good version records only the step and a coarse invitation range.
- The telemetry is typed, so adding a new event requires naming safe properties up front.
System Example
Safe telemetry: System Example
At system scale, safe telemetry keeps analytics calls consistent across routes, jobs, and experiments. A small local event catalog is often enough.
Larger System-Level Bad TypeScript Example
export async function finishTrial(req: any, services: any) {
const account = await services.accounts.finishTrial(req.body.accountId);
services.analytics.track("trial_finished", {
account,
user: req.user,
requestBody: req.body,
cookie: req.headers.cookie,
});
return account;
}
export async function finishTrial(
req: any,
services: any,
) {
const account =
await services.accounts.finishTrial(
req.body.accountId,
);
services.analytics.track(
"trial_finished",
{
account,
user: req.user,
requestBody: req.body,
cookie: req.headers.cookie,
},
);
return account;
}
Larger System-Level Good TypeScript Example
type Plan = "free" | "pro" | "team";
type TrialOutcome = "converted" | "expired";
type TelemetryEvent =
| {
name: "trial.finished";
properties: {
plan: Plan;
outcome: TrialOutcome;
seatsRange: "1" | "2-5" | "6+";
};
}
| {
name: "trial.finish_failed";
properties: {
reason: "not-found" | "already-finished";
};
};
type Analytics = {
track(event: TelemetryEvent): void;
};
type Account = {
id: string;
plan: Plan;
seats: number;
trialOutcome: TrialOutcome;
};
type AccountService = {
finishTrial(accountId: string): Promise<Account>;
};
function seatsRange(seats: number): "1" | "2-5" | "6+" {
if (seats === 1) return "1";
if (seats <= 5) return "2-5";
return "6+";
}
export async function finishTrial(accountId: string, accounts: AccountService, analytics: Analytics) {
const account = await accounts.finishTrial(accountId);
analytics.track({
name: "trial.finished",
properties: {
plan: account.plan,
outcome: account.trialOutcome,
seatsRange: seatsRange(account.seats),
},
});
return account;
}
type Plan = "free" | "pro" | "team";
type TrialOutcome = "converted" | "expired";
type TelemetryEvent =
| {
name: "trial.finished";
properties: {
plan: Plan;
outcome: TrialOutcome;
seatsRange: "1" | "2-5" | "6+";
};
}
| {
name: "trial.finish_failed";
properties: {
reason:
| "not-found"
| "already-finished";
};
};
type Analytics = {
track(event: TelemetryEvent): void;
};
type Account = {
id: string;
plan: Plan;
seats: number;
trialOutcome: TrialOutcome;
};
type AccountService = {
finishTrial(
accountId: string,
): Promise<Account>;
};
function seatsRange(
seats: number,
): "1" | "2-5" | "6+" {
if (seats === 1) return "1";
if (seats <= 5) return "2-5";
return "6+";
}
export async function finishTrial(
accountId: string,
accounts: AccountService,
analytics: Analytics,
) {
const account =
await accounts.finishTrial(accountId);
analytics.track({
name: "trial.finished",
properties: {
plan: account.plan,
outcome: account.trialOutcome,
seatsRange: seatsRange(account.seats),
},
});
return account;
}
What Changed
- The bad version copies account, user, request, and cookie data into analytics.
- The good version has an explicit event catalog with only the fields needed for reporting.
- Account identifiers stay in the product database instead of becoming analytics dimensions by default.
When To Use It
Safe telemetry: When To Use It
Use This When
- Product, growth, support, or reliability decisions depend on measuring a workflow.
- Events are emitted from more than one place and need consistent names and fields.
- The data may leave the main app boundary for analytics, observability, or data warehouse tools.
Avoid This When
- Nobody has named the product question the event answers.
- The only way to make the event useful is to include raw user text, secrets, tokens, or full payloads.
- The event would duplicate data already available in a safer aggregate report.
Tradeoffs
Safe telemetry may collect less detail than a raw dump. That is the point. You trade ad hoc curiosity for metrics that are stable, explainable, and safer to retain.
Related Concepts
- Structured errors
- Typed failure modes
- Tracing
Practice Prompt
Safe telemetry: Practice Prompt
Beginner Exercise
Find one analytics call that sends a full object. Replace it with an event name and two or three allowlisted properties.
Intermediate Exercise
Create a TelemetryEvent union for one workflow. Make the analytics client accept only that union, then update call sites to use the typed events.
Stretch Exercise
Review a telemetry event with a product manager. Write down the exact question it answers, then remove any property that does not help answer that question.
Reflection Question
Which fields would be useful for analysis but too sensitive or too identifying to send to analytics?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.