· 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:

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:

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:

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:

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:

  1. Set logLevel: LogLevel.debug in the Configuration for debug builds, and read the SDK log while you tap through the flow.
  2. Use a separate Amplitude project (or API key) for development, so test taps never mix with real users.
  3. 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