· 6 min read
Amplitude Event Naming in Flutter: Plan Events Before Code
A simple Amplitude event naming scheme and tracking plan for a Flutter app, plus a typed wrapper that keeps event names out of your widgets.
Amplitude event naming looks like the easy part of product analytics. You add the SDK, call track('button_clicked') in a few places and move on. A month later you open a funnel and find map_opened, Map Opened and openMap side by side, all meaning the same thing. Nobody can trust the chart, so nobody uses it. This post shows how I plan events for a Flutter app like Mapika before I write any tracking code: one naming rule, a small tracking plan, and a typed wrapper so event names never live in widgets.
Why event names break first
Amplitude stores whatever string you send. It does not know that share_card and Share Card Created are the same action. Every new spelling becomes a new event, and every chart built on the old one quietly loses data.
This happens for three ordinary reasons:
- Different people, different habits. One developer writes snake_case, another writes Title Case.
- Names describe the UI, not the action.
blue_button_tapstops making sense the day the button turns green or moves to another screen. - Strings are scattered. When
track('...')is called from twenty widgets, nobody sees the full list, so nobody notices the duplicates.
None of this is an Amplitude problem. It is a planning problem, and it is cheap to fix before launch and expensive to fix after.
One Amplitude event naming rule
Pick one rule and write it down. The one I use is Object + Past-Tense Verb, in Title Case:
Country Marked
Share Card Created
Badge Earned
Paywall Viewed
Subscription Started
Why this shape works:
- The object comes first. In Amplitude's event list, all
Share Card ...events sort together. You find related events without searching. - Past tense means it happened.
Share Card Createdfires after the card exists, not when the user taps a button that might fail. - It survives UI changes. The action stays the same when the screen is redesigned.
Amplitude's own data planning guide recommends a consistent naming convention, and this is one of the common ones. The exact style matters less than using only one.
Properties carry the detail
The second rule: do not put detail in the event name. If you need to know what was marked, that is a property, not a new event.
Bad: Country Marked Visited, Country Marked Lived, Country Marked Want
Good: Country Marked { status: "visited" | "lived" | "want_to_visit" }
Three events become one. Now a single chart shows all marks, and you can split it by status when you need to. Property names use snake_case, and values come from a fixed list so a typo cannot create a fourth status.
A few more property rules that save pain later:
- Use the same property name everywhere. If it is
sourceon one event, it is notoriginon another. - Prefer small, fixed sets of values. Free text is hard to group.
- Never send personal data in properties: no names, emails or free-text notes.
Write the tracking plan first
Before any code, I write a short table. It lives in the repo next to the code, so it changes in the same pull request as the events.
| Event | When it fires | Properties | Question it answers |
|---------------------|-------------------------------------|-------------------------------------|--------------------------------------|
| Onboarding Completed| last onboarding step is finished | skipped: bool | Do people finish onboarding? |
| Country Marked | a place is saved with a status | status, level (country/region/city) | Is the core action used? |
| Share Card Created | the card image is generated | format | Do people share their map? |
| Paywall Viewed | the paywall is on screen | source | Where do people meet the paywall? |
| Subscription Started| the store confirms the purchase | plan | Which entry point leads to a sale? |
These are example events for a travel map app, not a dump of Mapika's real setup. The point is the last column. Every event must answer a question you actually plan to ask. If you cannot write the question, you do not need the event yet.
This keeps the list short. A short list is easier to keep clean, and it is enough to build the first funnel (onboarding to first mark to share) and a retention chart based on the core action.
A typed wrapper in Flutter
The tracking plan is only useful if the code cannot drift from it. In Flutter, the fix is an interface and one class that knows about Amplitude. It is the same idea Mapika uses for ads and subscriptions with AdService and PremiumService: the app talks to an abstract service, and only one class knows the vendor.
enum MarkStatus { visited, lived, wantToVisit }
abstract class AnalyticsService {
Future<void> init();
void countryMarked({required MarkStatus status, required String level});
void shareCardCreated({required String format});
void paywallViewed({required String source});
}
Each method is one row of the tracking plan. A Cubit calls analytics.countryMarked(...). It never sees an event string, and the compiler catches a missing property.
The Amplitude implementation is the only place where names exist as strings:
import 'package:amplitude_flutter/amplitude.dart';
import 'package:amplitude_flutter/configuration.dart';
import 'package:amplitude_flutter/events/base_event.dart';
class AmplitudeAnalyticsService implements AnalyticsService {
AmplitudeAnalyticsService(this._apiKey);
final String _apiKey;
late final Amplitude _amplitude;
@override
Future<void> init() async {
_amplitude = Amplitude(Configuration(apiKey: _apiKey));
await _amplitude.isBuilt;
}
void _track(String name, Map<String, Object?> props) {
_amplitude.track(BaseEvent(name, eventProperties: props));
}
@override
void countryMarked({required MarkStatus status, required String level}) =>
_track('Country Marked', {'status': _status(status), 'level': level});
@override
void shareCardCreated({required String format}) =>
_track('Share Card Created', {'format': format});
@override
void paywallViewed({required String source}) =>
_track('Paywall Viewed', {'source': source});
String _status(MarkStatus s) => switch (s) {
MarkStatus.visited => 'visited',
MarkStatus.lived => 'lived',
MarkStatus.wantToVisit => 'want_to_visit',
};
}
The calls follow the Amplitude Flutter SDK docs: build Amplitude with a Configuration, wait for isBuilt, then track a BaseEvent. Check the import paths against the SDK version you install.
This wrapper gives you three things for free:
- One file to review. A new event is a new method plus a new row in the plan. Both show up in the same pull request.
- Tests without the network. Bind a fake
AnalyticsServicein tests and assert that a Cubit calledcountryMarkedonce. - A cheap exit. If you ever move to another analytics tool, you write one new class and change one DI binding. Cubits and UI stay as they are.
Check events before they reach your charts
Bad data is hard to remove after it lands, so I check events on a debug build first:
- Set
logLevel: LogLevel.debugin theConfigurationfor debug builds, and read the SDK log while you tap through the flow. - Use a separate Amplitude project (or API key) for development, so test taps never mix with real users.
- Walk through the tracking plan row by row and confirm each event fires once, at the right moment, with every property filled in.
The "once" part matters. An event fired from build() or from a stream listener that subscribes twice will double your numbers, and the chart will look healthy while being wrong.
If you already test flows with Maestro, a smoke test that walks the onboarding is a good moment to watch the event log too. I wrote about Maestro smoke tests in GitHub Actions earlier.
Takeaways
- Pick one Amplitude event naming rule, such as Object + Past-Tense Verb in Title Case, and write it down.
- Put detail in properties, not in event names. One event with a
statusproperty beats three near-duplicate events. - Keep a short tracking plan in the repo. Every event must answer a question you plan to ask.
- Hide Amplitude behind an
AnalyticsServiceinterface, so event strings live in one file and widgets never see them. - Verify every event on a debug build with a separate project before it reaches real charts.