What will you build?
An event taxonomy of seven names, spelled the way a dashboard reads them, declared once in Kotlin and reaching a Swift sink and a Kotlin sink unchanged. The grammar comes from the family’s telemetry artifact: an event is a subject, a verb and a list of primitive parameters, and the artifact ships ten starter verbs. Foyer adds one verb of its own,Signed In, and reaches for the starter vocabulary for the other six. Each event is emitted as a Track effect from the reducer that owns the transition, so tools/duet record writes it into the recordings and tools/duet verify fails when it moves. A fan-out worker at each composition root holds the sink list; the console sink behind it is a worker in its own right, adopted for the mount’s lifetime, and a test on each platform drives it through the worker harness. Expect about an hour and a half.
You will have at the end:
- A
src-kmp/telemetrymodule with the app’s one verb and a test that pins how it renders; seven<Feature>Eventsobjects, one beside each reducer that emits. - A
Trackeffect on seven features, asserted in their scenarios and in five chains, with 26 recordings re-recorded to carry the event envelope. - A console sink worker on Android and on iOS behind the artifact’s fan-out, each adopted at the root, each with a test through
WorkerTester. - Two tests that read every event envelope back out of the recordings, one with the Kotlin grammar and one with its Swift twin, and hold the set of names to the seven declared.
Where do you start?
Opentutorial9-start from the duet-tutorials repository. It is Tutorial 6’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1 and the KSP mock processor 0.2.1 with Tutorial 6’s toolchain. Tutorials 7, 8 and 9 all open this same tree. Run the checks once before you edit anything:
Tutorial9ExerciseTrackedEventsTest in the sign-in module. That test is the exercise at the end of this page: the sign-in event is the one you emit yourself. The six others are the steps.
The steps
Link the grammar
dev.modaal.duet.services:telemetry, already in the version catalog since Tutorial 6 as duet-services-telemetry. Its commonMain carries TrackedEvent, TrackedVerb, TrackedParam, the one encoding rule that turns an event into a vendor-facing name, and the port AnalyticsTracking; its JVM half adds the worker-typed sink port AnalyticsTrackingWorking and the fan-out AnalyticsTrackingWorker. The app links it through one module of its own, src-kmp/telemetry, which re-exports the artifact and holds what only this app can say:TrackedEvent:TrackedEvent nameable in a reducer; the Kotlin lane admits it because the module’s own dependencies are family artifacts only:src-kmp/app/build.gradle.kts gains the same two lines under implementation.Mint the app's verb
TrackedVerb is a token, not an enum case: the artifact declares Viewed, Opened, Started, Completed, Failed, Created, Edited, Deleted, Toggled and Signed Out as companion values, and an app that needs another declares it in one line. The token is the vendor-facing word: encodedName() splices it after the subject, so TrackedVerb("Signed In") on the Session subject renders as Session Signed In on every platform and in every sink. Foyer needs one verb the artifact does not ship:tools/duet verify never reaches it, because the module is not a manifest feature, and step 8 puts it into the workflow:Viewed, not Shown, and the name is Edited, not Changed, because both acts are in the starter vocabulary already: a second spelling of the same act is a second column on every dashboard. The one verb the app mints is the one the vocabulary lacks. The parameters follow the grammar’s privacy rule: primitives that describe behavior, never content. Name Edited carries nothing because the name is the user’s; Session Signed In carries which kind of provider, never the address; Onboarding Completed carries how many preferences were picked, not which.Declare the events beside the reducers
OnboardingEvents.completed(preferenceCount:), HomeEvents.promoViewed, UpgradeEvents.completed(plan:) and AccountEvents.signedOut follow the same two shapes; SignInEvents is the exercise. The objects are Kotlin and only Kotlin: the Swift side never spells an event, because the recordings and the framework carry the declaration across.Emit them as Track effects
notifyListener: the flow calls the environment and emits no action. The environment member is the one line the generated mock grows too, so RootEnvironmentMock has a trackArgs list on the next test compile.Then the reducer arms. The root is the interesting one, because the splash notifies its host on every completion path and Tutorial 1 pinned that deliberately in splash.both-paths-notify-twice. The launch is counted where one completion is acted on, and a second completion, late or while the first waits for auth, writes nothing and tracks nothing:awaitingAuth guard is new: before this step a second completion while waiting re-wrote the same flag, which no recording could see. Now it would emit a second event, so the guard makes the case explicit and the scenario pins it in the next step. The home tab tracks the promo and not the summary, the flow tracks the purchase when the port answers and not when the user leaves the last step, and the account screen tracks the sign-out when the port confirms:Completed moves the root’s phase, and the root shell tears the gate down; the editor’s Saved clears the account screen’s child. Effects run in list order, and the iOS root shell applies a phase change synchronously inside the delegate call, so an effect listed after the delegate is cancelled with the store before it runs. On Android the transition observer runs on a later hop and the second effect gets through, which is the kind of platform difference a recording cannot see and a console can, and step 8 shows the line that found it. The editor’s SaveFinished, the onboarding level’s last Continued and the account screen’s SignedOut take the same shape: event, then delegate. Nothing in any arm asks whether analytics is enabled. Consent gates the sink, never the emission, and that is what keeps every recording deterministic.Assert them in the scenarios and re-record
Track effect is asserted where every effect is: in the scenario’s thenEffects, as the exact list. The root’s first branch grows a step for the guard, and the branch that already pinned a late completion now pins that it tracks nothing either:case, the event’s subject and verb, and each parameter in the grammar’s canonical form, case naming the primitive kind. The Kotlin encoders wrote it, and step 8 reads it back with both languages’ decoders. tools/duet verify reports 73 of 73 and tools/duet record --check reports every feature and chain up to date; the mutation drill from Tutorial 6 gains nothing here, because a deleted emission fails the recording that pins it.Wire the sink on Android
Track effect has to reach a sink. The artifact’s fan-out, AnalyticsTrackingWorker, holds a list of sinks and forwards every call to each; a sink implements AnalyticsTrackingWorking, which is the tracking port plus Working, so the composition root adopts it like the session and entitlement workers from Tutorial 4. Foyer’s one sink prints:AnalyticsProviding, the artifact’s one-member Dependency interface, so that every level’s Dependency can extend it and be satisfied by the same member:AnalyticsProviding and its live environment forwards the effect; the compiler walks the chain from the root down, which is the same discipline Tutorial 3 set for the ports:Main and Profile extend it too, though their reducers emit nothing: a level’s Dependency is the union of its subtree’s needs. The sink’s test goes through WorkerTester, as the two stream workers’ do, and one of its three cases pins the fan-out’s seed, the reason a disabled sink never egresses in the gap between composition and the first consent flip:./gradlew :app:testDebugUnitTest runs it beside the app’s other suites; RootFlowTest, the headless walk over the whole tree, needs no change, because the root Component builds its own sink.Wire the sink on iOS
DuetTelemetry in the shared-services package: the artifact’s fan-out is JVM-only, and a Kotlin Track effect carries the framework’s bridged TrackedEvent class, not the Swift package’s struct. So the tree gains the package, and one file that holds both grammars and converts between them. It lives in FoyerBridge, the target every shell and every spec already shares:BridgedAnalyticsWorker wears the bridged port, holds the package’s AnalyticsTrackingWorker, and converts each crossing event before fanning it out. The conversion copies the subject, carries the verb token verbatim, and maps the four parameter kinds; SKIE projects the Kotlin sealed interface as an enum, so the switch is exhaustive:Working is Sendable, so its enabled flag lives behind a lock rather than in a plain property:analytics, the member every level’s Dependency names; the shell adopts the sink and the fan-out before the child slot exists:tools/duet mocks generates the Components and the test doubles from, so regenerate; the generated HomeDependencyMock now takes analytics: in its initializer, and every shell spec passes the working default, a fan-out over no sink:ConsoleAnalyticsSinkSpec is the Android test’s twin, through the Swift WorkerTester. The Apple lane runs both after it assembles the core: parity/scripts/apple-boundary-lane.sh replays 67 recordings across the framework, Track envelopes included, and runs the shells’ 41 specs.Take the receipt
track envelope with the grammar’s own serializer, and holds the set of encoded names to the seven declared; it lives in :telemetry and not in a feature module on purpose, because tools/duet record runs a feature module’s whole test task, and this test cannot pass until the recording it reads exists:Codable, and re-encodes each event to check it writes back what Kotlin wrote:println, which reaches logcat under the System.out tag; on iOS it prints to the process’s standard output, which xcrun simctl launch --console-pty shows. Launch each, sign in as a guest, and the first two lines are the same on both:encodedName() and encodedProperties(), the two things a vendor SDK is handed. This is also the check that found the ordering rule in step 4: with the event listed after the delegate, Android printed both lines and the iOS console stopped after the first, because the gate was torn down before its second effect ran. Swap the console sink for a vendor’s and nothing above the composition root changes.What you now have
- Seven events with dashboard-ready names, declared once in Kotlin beside the reducers that emit them, reaching a Swift sink and a Kotlin sink unchanged; one verb of the app’s own, and a test that pins how it renders.
- Every emission pinned as an effect in a recording, with its parameters, and asserted in the scenario and in the chains that cross it; a repeated or late completion pinned as tracking nothing.
- A console sink worker on each platform behind the artifact’s fan-out, adopted at the root before any child can emit, with a test through the worker harness on each side.
- The taxonomy read back out of the recordings by both languages’ decoders, so an eighth event is a deliberate edit in two lists, and a grammar module suite that runs as one step of the workflow.
Exercise: emit the sign-in event
tutorial9-start carries Tutorial9ExerciseTrackedEventsTest, a failing placeholder in the sign-in module. The taxonomy’s second row is still empty on the tree: emit Session Signed In from signInReducer when the auth port answers signedIn, with one parameter, provider, whose value is email or guest from the provider held in pending. The steps above give you the four shape edits and the SignInEvents object to write; the verb is AppVerbs.SignedIn. Assert the event in signin.email-signs-in and signin.guest-signs-in, re-record the feature and chain-root-signin, which crosses the emitting step, and replace the placeholder with SignInTrackedEventsTest: a TestStore over the generated environment mock that checks the event reached trackArgs once with the name and the bag a vendor would see, and that a refused sign-in reached it not at all.
tutorial9-complete carries the finished event and test. The address never enters the event: SignInEvents.signedIn maps the provider to its kind, and the test’s last assertion is the property bag with one key.
Common questions
Why does the root emit the launch event, and not the splash?
Why does the root emit the launch event, and not the splash?
splash.both-paths-notify-twice: a completion latch in the splash would hide a bug the safety net exists to catch. A Track there would fire twice per launch. The root is where one of the two completions is acted on and the other is inert, so the root owns the count, and its recordings pin that a late or repeated completion tracks nothing. An event belongs to the reducer that decides the transition, which is not always the one that first sees the input.Why Promo Viewed and Name Edited, when the plan said Shown and Changed?
Why Promo Viewed and Name Edited, when the plan said Shown and Changed?
Viewed and Edited are in the artifact’s starter vocabulary and describe the same acts. A verb is a taxonomy decision: every event with that verb becomes a family of dashboard names, and two spellings of one act split a chart in two for as long as both exist. The rule is to reach for a starter verb first and mint one only for an act the vocabulary lacks, which is why Foyer mints exactly Signed In, the pair of the starter Signed Out.Why is the emission unconditional, and where does consent go?
Why is the emission unconditional, and where does consent go?
setEnabled(false) on the fan-out reaches every sink, and each sink drops what it receives while disabled, which is where a vendor SDK’s own opt-out switch lives anyway. The fan-out pushes the seed into every sink in its constructor, so nothing egresses before the app’s persisted choice is applied. Foyer seeds true and has no Settings row; an app that ships a vendor adds the row, persists the choice, and forwards it to setEnabled. identify(uid:) and reset() are the same shape: composition-root calls on the session stream, never reducer effects.Why a console sink and not a real vendor?
Why a console sink and not a real vendor?
AnalyticsTrackingWorking, the only file that imports an SDK, added to the sink list at the root and adopted in its own right. Everything above the root, and every recording, is identical with a real sink in that list.What about events the reducers never see, like a screen appearing?
What about events the reducers never see, like a screen appearing?
analytics.track directly on the same grammar types, with no Track effect, because no reducer decided anything. Nothing in Foyer needs one today; the seven events are all transitions. The split rule is the one from the grammar’s own documentation: reducer-driven events are effects and fixture-gated, lifecycle events are shell calls and are not.What do the package's twin fixtures pin, and what do the app's?
What do the package's twin fixtures pin, and what do the app's?
BridgedAnalyticsWorker copy fields instead of translating. The app’s envelope tests pin something the package cannot: that this app’s seven events, as recorded, decode with both grammars and name exactly the taxonomy. Step 8’s two tests are the app’s contribution to the same contract.Why do 13 recordings change when nothing in them moved?
Why do 13 recordings change when nothing in them moved?
tools/duet record marks those rewrites as metadata-only, and the drift check treats them the same as any other change: a recording is what the scenario produces today, line numbers included.Sources and further reading
- duet-services —
DuetTelemetryand its Kotlin twindev.modaal.duet.services:telemetry: the grammar, the encoding rule, the sink port, the fan-out worker, and the twin fixtures undercontracts/telemetry-twin. - Tutorial 4: Workers — the worker seam and
WorkerTester, which the two sink tests use. - Tutorial 6: The Checks in CI — the workflow the grammar module’s step joins.
- The Duet glossary — effect, worker and golden recording.