03 Safe telemetry 6 chapters

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.

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