Skip to content

Building an Analytics Codegen Pipeline

· 6 min read
analytics

In our iOS project most analytics work followed the same flow: someone asks to investigate, add, or change an event, and the task turns into tracing calls, checking event names against a spreadsheet, and remembering that the same event goes by a different name in Amplitude than in Firebase. It was all manual, and easy to get wrong in small ways, and it seemed like a good candidate for code generation, especially given how cheap and quick building a custom code generator has become with AI.

To address this I created a small pipeline where our events are defined once in YAML, and everything else is generated from that definition. If you’ve worked with Apollo for GraphQL or OpenAPI for API models, it’s the same idea: the contract lives in one artifact, and typed Swift is generated from it.

The architecture

There are four pieces:

  1. YAML files which define our events.
  2. A code generator turns each definition into a typed Swift struct, along with the provider-specific payloads underneath.
  3. App code constructs those structs at call sites.
  4. An analytics manager fans each event out to its destinations.

The flow is unidirectional: YAML to Swift to providers. Everything between the contract and the call site is generated.

Architecture: YAML contract and app call sites are hand-written; event structs, provider payloads, and routing are generated; an analytics manager fans events out to Amplitude and Firebase at runtime Architecture: YAML contract and app call sites are hand-written; event structs, provider payloads, and routing are generated; an analytics manager fans events out to Amplitude and Firebase at runtime

Defining events in YAML

Each domain in the app — Search, Onboarding, Notifications, Settings — gets its own YAML file. Here’s a single event from one of them:

domain: Search

events:
  TapSearchResult:
    description: User taps a result from the search results list.

    properties:
      query:
        type: string
      resultId:
        type: string
      resultType:
        type: string
      position:
        type: int

    providers:
      amplitude:
        eventName: "Search Result Tapped"
      firebase:
        eventName: "select_content"

The top half is the contract: the event, its properties, and their types. This is our model — the domain, naming, and property shapes that make sense for our product. It’s the part product, QA, and data align on, and it maps one-to-one onto the spreadsheet row it came from.

The bottom half is translation. The same tap goes out as “Search Result Tapped” to Amplitude and select_content to Firebase. The Amplitude name follows our convention. The Firebase name has to match its taxonomy so the built-in reports work.

The generated Swift

The generator turns each event into a Swift struct, namespaced by domain:

extension SearchEvent {
    struct TapSearchResult: AnalyticsEvent {
        let query: String
        let resultId: String
        let resultType: String
        let position: Int

        var amplitude: ProviderEvent? {
            ProviderEvent(name: "Search Result Tapped", properties: [
                "query": query,
                "result_id": resultId,
                "result_type": resultType,
                "position": position
            ])
        }

        var firebase: ProviderEvent? {
            ProviderEvent(name: "select_content", properties: [
                "query": query,
                "result_id": resultId,
                "result_type": resultType,
                "position": position
            ])
        }
    }
}

Each destination gets a computed property producing a provider-ready payload — the final name, the final keys. ProviderEvent is just that pair:

struct ProviderEvent {
    let name: String
    let properties: [String: Any]
}

The AnalyticsEvent protocol declares one optional property per destination, defaulting to nil:

protocol AnalyticsEvent {
    var amplitude: ProviderEvent? { get }
    var firebase: ProviderEvent? { get }
}

extension AnalyticsEvent {
    var amplitude: ProviderEvent? { nil }
    var firebase: ProviderEvent? { nil }
}

Routing is generated too. The YAML’s providers: block determines which computed properties exist; an event that doesn’t list a destination returns nil for it, and the fan-out skips it. The tracking layer never inspects events:

func track(_ event: AnalyticsEvent) {
    if let amplitude = event.amplitude { amplitudeClient.track(amplitude) }
    if let firebase = event.firebase { firebaseClient.track(firebase) }
}

At the call site, tracking is constructing a value:

analytics.track(
    SearchEvent.TapSearchResult(
        query: searchText,
        resultId: result.id,
        resultType: result.type,
        position: index
    )
)

Type SearchEvent. and autocomplete lists every event in the domain, and each constructor shows which properties it needs.

Running the generator

Generation runs from the command line with make analytics. The script does two things in order:

  1. Validates the YAML against the schema — well-formed, known provider keys, valid types.
  2. Emits the Swift.

The validation doesn’t check that the strings are the ones you meant — a schema-valid contract can still be wrong. The generator itself has unit tests that verify its output compiles.

The generated Swift is committed alongside the YAML, so a contract change shows up in a PR as both the YAML diff and its generated Swift. Review concentrates on the YAML, because the Swift is derived from it and doesn’t need to be checked line by line.

The generator is a script written largely with AI assistance — a mechanical, well-specified transformation is in the sweet spot for current tools. The AI helped write the generator once; the pipeline itself is deterministic, producing the same Swift from the same YAML every time.

What this changes

For us, the contract and the code can’t drift apart anymore. Whatever names, properties, and types the YAML defines are exactly what our generated structs produce — there’s no second copy in app code to fall out of sync. An analytics change is now a YAML diff, and a reviewer reads a few lines of the contract instead of auditing call sites and provider wiring.

Shifting the analytics contract left: from spreadsheet to executable source of truth Shifting the analytics contract left: from spreadsheet to executable source of truth

It doesn’t make the contract correct. A property misspelled in the YAML is faithfully generated into Swift. But a mistake can only exist in one place now, and reviewing analytics means reviewing the YAML.

Still on the list

  1. Regeneration is manual, so nothing yet stops a YAML change from merging without its regenerated Swift. A CI step that runs make analytics and fails on a dirty diff would close that.
  2. The contract is only checked against its own schema. What the providers expect downstream goes unchecked — validating the YAML against their real schemas is the next step.
  3. The spreadsheet still exists upstream; the YAML is a copy the code can read.

This isn’t only about analytics. Any contract that reaches the code as a hand-typed copy can drift from its source. Generating it from a single artifact is how APIs and GraphQL already avoid that, and analytics fits the same pattern.