Amplitude implementation
Feature tracking
A template for instrumenting feature usage. Apply once per major area of your product.
Feature tracking isn't a single module — it's a template you apply to each major area of your product. This chapter covers the patterns; you'll repeat them for every feature you instrument.
The recommendation here: track most feature events client-side. Users are actively in your app when these fire, and the small loss to ad blockers rarely changes how you'd interpret the data. For events that absolutely need accuracy (revenue), server-side is the answer — but that's the next chapter, not this one.
Planning the events for a feature
Before writing code, map out the feature's user journey and identify the meaningful actions. Most useful events represent state transitions — moments when data is created, modified, or deleted.
Worked example: an invoicing feature. The user flow:
- User creates a new invoice (blank, or from a template)
- User adds line items, sets payment terms
- User sends the invoice to a client
- User may later edit, duplicate, or void the invoice
From this, the events worth tracking:
Invoice createdInvoice editedInvoice sentInvoice voidedTemplate createdTemplate used
Each fires only when the corresponding action actually succeeds (see Tracking on successful states). If the user clicks "Send" but the email fails, no Invoice sent event.
You may also want a few micro events to measure drop-off within flows (see Heavy events vs. micro events):
Customer dropdown openedCustomer selectedInvoice line item added
These fire on UI state changes, without server confirmation.
Picking properties
Properties answer questions about how a feature is used. For invoice creation, useful properties include:
from_template: true | falseline_item_count: 5total_amount: 1500currency: "EUR"template_id: "abc123"(if from_template is true)
Properties should answer business questions. They should not include PII — no customer names, no email addresses, no invoice IDs that could identify a specific person. These don't help understand product usage and create compliance risk.
A good test: would this property help you answer "are users with X behavior more likely to retain?" If yes, include it. If it's just descriptive metadata for a specific record, leave it out.
Heavy events (state changes)
Heavy events wait for server confirmation. The pattern:
"use client";
import { createInvoice } from "./actions";
import amplitude from "@/components/amplitude-client";
export function CreateInvoiceForm({ customers }: { customers: Customer[] }) {
async function handleSubmit(formData: FormData) {
const result = await createInvoice(formData);
if (result.success) {
amplitude.track("Invoice created", {
from_template: formData.get("template_id") ? true : false,
line_item_count: formData.getAll("line_items").length,
total_amount: result.invoice.totalAmount,
currency: result.invoice.currency,
});
} else {
amplitude.track("Error occurred", {
feature: "invoice_creation",
error_type: result.error,
});
}
}
return (
<form action={handleSubmit}>
{/* form fields */}
</form>
);
}The event fires only after result.success confirms the invoice was actually created. If validation fails or the network drops, the success event doesn't fire — but the error event does, with enough context to identify what failed.
Why include an error event?
Tracking errors as a separate event lets you measure how often features fail for users. This isn't operational debugging (that's what your error logger is for) — it's understanding how often users hit a wall in your product.
If 5% of "attempted invoice creation" requests result in an error event, that's actionable. You can find the most common error types, fix the worst offenders, and measure the impact.
You don't need a separate event per failure type. One Error occurred event with a feature property and an error_type property keeps your taxonomy clean while preserving the granularity to filter when needed.
When not to track errors
Only track errors that affect the user-facing experience. Skip:
- Network timeouts that retry transparently
- Validation errors before the request is sent (these don't reach your server)
- Backend errors that don't surface to the user (background job failures, async retries)
These belong in your error logger, not your analytics. The bar for tracking an error in Amplitude is: "did the user actually see this fail?"
Micro events (UI state changes)
Micro events fire on UI state changes, immediately, without server confirmation.
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import amplitude from "@/components/amplitude-client";
export function CustomerSelector({ customers }: { customers: Customer[] }) {
return (
<Select
name="customer"
onOpenChange={(open) => {
amplitude.track(open ? "Customer dropdown opened" : "Customer dropdown closed");
}}
onValueChange={() => {
amplitude.track("Customer selected");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a customer" />
</SelectTrigger>
<SelectContent>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>{customer.name}</SelectItem>
))}
</SelectContent>
</Select>
);
}These events power funnel analyses. When you're trying to understand why users abandon the invoice creation flow, the funnel from "Customer dropdown opened" → "Customer selected" → "Invoice created" tells you where they stop.
Don't over-track micro events
It's easy to instrument every UI interaction once you've got the pattern. Resist this. The rule of thumb: track micro events when you have a specific question about drop-off in a known flow, or when you suspect a UI element isn't being noticed.
If you're tracking 15 micro events on a single screen, you've over-instrumented. The signal gets buried in the noise. Pick the 2–3 that matter for the questions you're actually trying to answer.
Server-side feature tracking
If your team prefers tracking from the server, the pattern matches the auth module. Pass device ID and session ID through the form submission so the event stitches into the user's session:
import { track } from '@amplitude/analytics-node';
export async function createInvoice(formData: FormData) {
const userId = await getCurrentUserId();
const deviceId = formData.get("amplitudeDeviceId") as string;
const sessionId = formData.get("amplitudeSessionId") as string;
try {
const invoice = await db.invoices.create({
// ...
});
await track("Invoice created", {
from_template: formData.get("template_id") ? true : false,
line_item_count: formData.getAll("line_items").length,
total_amount: invoice.totalAmount,
currency: invoice.currency,
}, {
user_id: userId,
device_id: deviceId,
session_id: sessionId ? parseInt(sessionId, 10) : undefined,
});
return { success: true, invoice };
} catch (error) {
if (error.type === 'VALIDATION_ERROR') {
await track("Error occurred", {
feature: "invoice_creation",
error_type: "validation_error",
}, {
user_id: userId,
device_id: deviceId,
session_id: sessionId ? parseInt(sessionId, 10) : undefined,
});
return { success: false, error: error.message };
}
// Operational error — log it, don't track it
logger.error(error, { context: 'invoice_creation', userId });
return { success: false, error: 'Something went wrong' };
}
}Server-side gives you guaranteed delivery and ties tracking to your database transaction. It's more code, but for some events it's the right choice — particularly if your team prefers keeping data operations in one place.
For most feature events, client-side is sufficient. Save server-side for the events that absolutely need accuracy.
Build incrementally
Once you've instrumented one feature, move to the next. Don't try to track everything at once — you'll end up with a sprawling tracking plan you can't validate.
Pick features by impact:
- Which features are critical to your activation flow?
- Which features do paying users use heavily?
- Which features have you been guessing about?
Track one. Validate it. Build a report. Then move on.
Validation
For each feature you instrument, verify:
Events fire correctly.
- Heavy events fire only after the server confirms success, not on click
- Micro events fire on UI state changes
- Error events fire when actions fail
- Event names match your taxonomy exactly
Properties are accurate.
- All listed properties appear on every event
- Values match expected types (numbers as numbers, not strings)
- No PII or sensitive data accidentally included
Cross-browser works.
- Events fire in incognito/private browsing
- Ad blockers don't break the page (events may be lost, but the app should work)
- Mobile browsers behave the same as desktop
Real user data looks reasonable.
- Pull a few users' timelines and walk through them — does the event sequence match what they'd have actually done?
- Check property distributions — are there impossible values, blank strings where data should exist?