DEV Community

Cover image for Measuring Mini-App Value Without Vanity Metrics
FinClip Super-App
FinClip Super-App

Posted on

Measuring Mini-App Value Without Vanity Metrics

A mini-app platform can make a new service remarkably easy to ship. That is useful until “easy to ship” becomes the team’s definition of success.

Catalogue size, page views, and total sessions all describe activity. They do not show whether a service completed a customer job, reduced a handoff, or created a durable reason to return. For an early super-app initiative, that distinction matters. The team is still deciding which service adjacencies deserve investment.

This article builds a small analytics contract for a host app and its mini apps. The design has four goals:

  1. Every service reports the same customer-journey events.
  2. The host supplies identity and journey context without exposing raw customer data.
  3. Event validation happens before ingestion.
  4. Metrics can distinguish exposure, activation, completion, continuity, and repeat use.

The examples use TypeScript, Zod, and SQL, but the contract is independent of any analytics vendor.

Begin with a customer job

UI events are usually too close to the implementation. A team renames a button, moves a card, or redesigns a screen and suddenly the analytics model needs to change.

A customer-job event is more stable. “Bill payment completed” survives several versions of the interface. It can also be compared across a native screen, a mini app, and an external handoff.

For a first contract, keep the vocabulary small:

  • service_impression: an eligible service was shown on an approved surface;
  • service_opened: the customer entered the service;
  • task_started: the customer began the job the service exists to support;
  • task_completed: the job reached a defined business outcome;
  • handoff_started: the journey left the current service;
  • handoff_returned: control returned after the external or cross-service step;
  • service_error: an error affected the journey.

Clicks can still be useful for interface experiments. Put them in a separate UI analytics stream; they should not become the primary evidence for service value.

Define a discriminated event union

The host and every mini app should share one package containing the event types. A discriminated union gives each event a stable name and event-specific properties.

export type Surface = "home" | "contextual" | "search" | "catalog";

type EventMap = {
  service_impression: {
    surface: Surface;
    position?: number;
  };
  service_opened: {
    entryPoint: Surface | "deep_link" | "notification";
  };
  task_started: {
    taskName: string;
  };
  task_completed: {
    taskName: string;
    outcome: "success" | "partial";
    durationMs: number;
  };
  handoff_started: {
    handoffId: string;
    targetType: "mini_app" | "native" | "web" | "human_support";
    targetId: string;
  };
  handoff_returned: {
    handoffId: string;
    targetType: "mini_app" | "native" | "web" | "human_support";
    outcome: "completed" | "cancelled" | "failed";
  };
  service_error: {
    errorCode: string;
    recoverable: boolean;
    stage: string;
  };
};

export type EventName = keyof EventMap;

export type MiniAppEvent<Name extends EventName = EventName> = {
  [Key in Name]: {
    eventName: Key;
    properties: EventMap[Key];
  }
}[Name];
Enter fullscreen mode Exit fullscreen mode

The mapped union prevents a task_completed event from being sent without a task name or duration. It also prevents properties from one event leaking into another.

Let the host create the envelope

A mini app should describe what happened inside its service. The host is better placed to add trusted platform context: service identity, version, journey, timestamp, and an opaque actor key.

export interface HostEventEnvelope {
  eventId: string;
  occurredAt: string;
  schemaVersion: "1.0";
  serviceId: string;
  serviceVersion: string;
  hostVersion: string;
  hostSessionId: string;
  journeyId: string;
  actorKey: string;
}

export type AnalyticsRecord = HostEventEnvelope & MiniAppEvent;

export interface HostAnalyticsContext {
  serviceId: string;
  serviceVersion: string;
  hostVersion: string;
  hostSessionId: string;
  journeyId: string;
  actorKey: string;
}

export function createAnalyticsRecord<Name extends EventName>(
  context: HostAnalyticsContext,
  event: MiniAppEvent<Name>
): HostEventEnvelope & MiniAppEvent<Name> {
  return {
    ...context,
    ...event,
    eventId: crypto.randomUUID(),
    occurredAt: new Date().toISOString(),
    schemaVersion: "1.0",
  };
}
Enter fullscreen mode Exit fullscreen mode

actorKey should be issued by the host and reviewed as part of the privacy design. It is an opaque, purpose-limited value—not an email address, account number, phone number, or advertising identifier. If repeat analysis only needs a 30-day window, rotate or expire the key accordingly.

journeyId follows a customer job across service boundaries. If a native payment screen opens an insurance mini app and then returns, the same journey identifier connects those events without requiring each component to know the customer’s identity.

Validate at runtime

TypeScript disappears at runtime. A compromised, old, or incorrectly implemented mini app can still send malformed JSON. Validate events at the host bridge and again at ingestion if events may arrive through other paths.

import { z } from "zod";

const common = {
  eventId: z.string().uuid(),
  occurredAt: z.string().datetime(),
  schemaVersion: z.literal("1.0"),
  serviceId: z.string().regex(/^[a-z][a-z0-9-]{2,63}$/),
  serviceVersion: z.string(),
  hostVersion: z.string(),
  hostSessionId: z.string().min(8),
  journeyId: z.string().min(8),
  actorKey: z.string().min(16),
};

export const AnalyticsRecordSchema = z.discriminatedUnion("eventName", [
  z.object({
    ...common,
    eventName: z.literal("service_impression"),
    properties: z.object({
      surface: z.enum(["home", "contextual", "search", "catalog"]),
      position: z.number().int().nonnegative().optional(),
    }).strict(),
  }).strict(),
  z.object({
    ...common,
    eventName: z.literal("service_opened"),
    properties: z.object({
      entryPoint: z.enum([
        "home", "contextual", "search", "catalog",
        "deep_link", "notification",
      ]),
    }).strict(),
  }).strict(),
  z.object({
    ...common,
    eventName: z.literal("task_started"),
    properties: z.object({ taskName: z.string().min(1) }).strict(),
  }).strict(),
  z.object({
    ...common,
    eventName: z.literal("task_completed"),
    properties: z.object({
      taskName: z.string().min(1),
      outcome: z.enum(["success", "partial"]),
      durationMs: z.number().int().nonnegative(),
    }).strict(),
  }).strict(),
  z.object({
    ...common,
    eventName: z.literal("handoff_started"),
    properties: z.object({
      handoffId: z.string().uuid(),
      targetType: z.enum(["mini_app", "native", "web", "human_support"]),
      targetId: z.string().min(1),
    }).strict(),
  }).strict(),
  z.object({
    ...common,
    eventName: z.literal("handoff_returned"),
    properties: z.object({
      handoffId: z.string().uuid(),
      targetType: z.enum(["mini_app", "native", "web", "human_support"]),
      outcome: z.enum(["completed", "cancelled", "failed"]),
    }).strict(),
  }).strict(),
  z.object({
    ...common,
    eventName: z.literal("service_error"),
    properties: z.object({
      errorCode: z.string().regex(/^[A-Z0-9_]{3,64}$/),
      recoverable: z.boolean(),
      stage: z.string().min(1),
    }).strict(),
  }).strict(),
]);
Enter fullscreen mode Exit fullscreen mode

The strict objects reject undeclared properties. This helps stop well-meaning teams from attaching raw customer attributes “temporarily” and turning the event pipeline into an undocumented data-sharing interface.

Put the bridge under host control

Expose a narrow function to the mini app. The bridge accepts only the event body; it ignores any attempt to submit host context.

export function createAnalyticsBridge(
  context: HostAnalyticsContext,
  enqueue: (record: AnalyticsRecord) => Promise<void>
) {
  return {
    async track<Name extends EventName>(event: MiniAppEvent<Name>) {
      const record = createAnalyticsRecord(context, event);
      const validated = AnalyticsRecordSchema.parse(record);
      await enqueue(validated as AnalyticsRecord);
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

A mini app can now report a completed task without accessing the pseudonymous actor key or manufacturing its own service identity:

await host.analytics.track({
  eventName: "task_completed",
  properties: {
    taskName: "schedule_bill_payment",
    outcome: "success",
    durationMs: 18_420,
  },
});
Enter fullscreen mode Exit fullscreen mode

In production, the queue should be asynchronous, bounded, and tolerant of network loss. Product analytics must never block the customer’s task. Use eventId for idempotency when clients retry.

Calculate a task funnel with the right denominator

The first useful metric chain is eligible impression to completed task. Assuming an analytics_events table with JSON properties, a PostgreSQL-style query might look like this:

SELECT
  service_id,
  COUNT(DISTINCT actor_key)
    FILTER (WHERE event_name = 'service_impression') AS reached_users,
  COUNT(DISTINCT actor_key)
    FILTER (WHERE event_name = 'service_opened') AS opened_users,
  COUNT(DISTINCT actor_key)
    FILTER (WHERE event_name = 'task_started') AS started_users,
  COUNT(DISTINCT actor_key)
    FILTER (
      WHERE event_name = 'task_completed'
        AND properties->>'outcome' = 'success'
    ) AS completed_users
FROM analytics_events
WHERE occurred_at >= :period_start
  AND occurred_at < :period_end
  AND service_id = :service_id
GROUP BY service_id;
Enter fullscreen mode Exit fullscreen mode

Do not replace reached_users with the host app’s entire monthly active population. A contextual travel service may only be eligible after a relevant purchase. The eligibility engine should record who could have received the placement, or the experiment pipeline should supply the eligible cohort separately.

Measure repeat value after completion

Returning after a completed job is usually more meaningful than returning after an impression. This query finds customers who completed the same task again between seven and thirty days after their first completion in the analysis period:

WITH successful_completions AS (
  SELECT
    actor_key,
    service_id,
    properties->>'taskName' AS task_name,
    occurred_at
  FROM analytics_events
  WHERE event_name = 'task_completed'
    AND properties->>'outcome' = 'success'
    AND service_id = :service_id
),
first_completions AS (
  SELECT
    actor_key,
    service_id,
    task_name,
    MIN(occurred_at) AS first_completed_at
  FROM successful_completions
  GROUP BY actor_key, service_id, task_name
  HAVING MIN(occurred_at) >= :cohort_start
     AND MIN(occurred_at) < :cohort_end
),
repeaters AS (
  SELECT DISTINCT
    f.actor_key,
    f.service_id,
    f.task_name
  FROM first_completions f
  JOIN successful_completions s
    USING (actor_key, service_id, task_name)
  WHERE s.occurred_at >= f.first_completed_at + INTERVAL '7 days'
    AND s.occurred_at < f.first_completed_at + INTERVAL '30 days'
)
SELECT
  f.service_id,
  f.task_name,
  COUNT(DISTINCT f.actor_key) AS first_completers,
  COUNT(DISTINCT r.actor_key) AS repeat_completers,
  ROUND(
    COUNT(DISTINCT r.actor_key)::numeric /
    NULLIF(COUNT(DISTINCT f.actor_key), 0),
    4
  ) AS repeat_completion_rate
FROM first_completions f
LEFT JOIN repeaters r
  USING (actor_key, service_id, task_name)
GROUP BY f.service_id, f.task_name;
Enter fullscreen mode Exit fullscreen mode

The seven-to-thirty-day window is an example, not a universal retention definition. A recurring payment service may justify it. Annual insurance renewal does not. Define the window in the service’s measurement plan, version it, and keep the query aligned with the intended customer job.

Observe continuity across services

A platform should also show whether it shortened a journey. Match handoff_started with handoff_returned by handoffId, retain journeyId for the wider customer job, and inspect:

  • the percentage of handoffs that return successfully;
  • time spent outside the originating service;
  • completion after the return;
  • repeated authentication or consent prompts;
  • movement to human support.

A lower number of external handoffs can be valuable even if total sessions remain unchanged. Conversely, a cross-service journey that creates extra opens but loses customers at the handoff should not be presented as improved engagement.

Add measurement guardrails

Before teams publish dashboards, document these rules:

Event ownership. A platform team owns the contract. A service owner owns the semantic definition of each task and outcome.

Schema evolution. Add fields compatibly within a version. Breaking meaning requires a new schema version and a migration plan.

Deduplication. Treat eventId as an idempotency key. Mobile clients retry after network loss.

Clock handling. Use the host timestamp for journey order and record server receipt time separately. Client clocks drift.

Privacy. Exclude raw personal data from event properties. Limit actor-key retention, access, exports, and joining with other datasets.

Failure isolation. Analytics queues must not delay a payment, booking, or support request.

Experiment context. Attach an approved experiment identifier through the host envelope if a placement is being tested. Do not let mini apps self-assign treatment groups.

Metric review. A service can have high completion and unacceptable support cost. Product, operations, risk, and customer-experience measures belong in the same decision.

Decide what the service has earned

After the test period, the analytics should support a placement decision:

  • Strong completion and repeat value may justify wider distribution.
  • Strong completion with low frequency may justify contextual or searchable access.
  • High opens and weak completion call for journey investigation, not more promotion.
  • High support cost or error rates may require suspension.
  • No incremental value may justify returning the task to an existing channel.

The point of the contract is not to manufacture a single “mini-app score.” It gives teams a shared account of reach, customer work, continuity, repeat behaviour, and operating cost.

Shipping an independent service is a platform capability. Learning whether it deserves to stay is an analytics capability. An early super-app initiative needs both.

References

Top comments (0)