Track product usage

See users metrics in your CRM

  1. onDaily cron at 6:00 AM ET
  2. attiopage through every user record
  3. posthogcount logged_in events per user, last 7 days
  4. attiowrite logged_in_7d to the linked person
  5. logreport the sync total

durable — pauses for approvals, survives restarts

Build it with your coding agent

Paste into any coding agent with the Terse skills installed. No skills yet? Install them in one command.

/terse-create build https://www.useterse.ai/use-cases/posthog-logins-to-attio.md

Sync your event data from Posthog to Attio and know how your users have been using your product.

Prerequisites

  1. A Terse project ( terse init )
  2. Connected Posthog and Attio integrations. Run terse integrate and follow steps to connect each one.

What this does

Syncs login event counts once a day from Posthog to Attio per user.

The job

typescript
import { createJob } from "terse-sdk";
import type { AttioRecordFor } from "../terse.generated";
import {
  AttioObject,
  PosthogProject,
  toolbox,
  Triggers,
} from "../terse.generated";
 
const POSTHOG_PROJECT: PosthogProject = PosthogProject.Terse;
 
const ATTIO_PAGE_SIZE = 100;
const USER_CONCURRENCY = 5;
 
createJob({
  name: "Sync PostHog CLI logins to Attio people",
 
  triggers: [
    Triggers.schedule.cron({
      expression: "0 6 * * *",
      timezone: "America/New_York",
    }),
  ],
 
  durable: true,
 
  onTrigger: async () => {
    const users = await fetchSyncableUsers();
    console.log(`Syncing ${users.length} Attio user(s)`);
 
    await chunk(users, USER_CONCURRENCY).reduce(
      async (previousBatches, batch) => {
        await previousBatches;
        await Promise.all(batch.map(syncUser));
      },
      Promise.resolve(),
    );
 
    console.log(`Updated ${users.length} person record(s)`);
  },
});
 
async function syncUser(user: SyncableUser): Promise<void> {
  // PostHog aggregates server-side: one call returns per-event counts for this user.
  const counts = await toolbox.posthog.listEventNames({
    projectId: POSTHOG_PROJECT.projectId,
    distinctId: user.userId,
    dateFrom: "-7d",
  });
  const loggedIn7d =
    counts.eventCounts.find((entry) => entry.eventName === "logged_in")
      ?.count ?? 0;
 
  await toolbox.attio.updateRecord({
    object: AttioObject.Person,
    recordId: user.personRecordId,
    values: { logged_in_7d: loggedIn7d },
  });
}
 
async function fetchSyncableUsers(offset = 0): Promise<SyncableUser[]> {
  const page = await toolbox.attio.queryRecords({
    object: AttioObject.User,
    limit: ATTIO_PAGE_SIZE,
    offset,
  });
  const syncable = page.records
    .map(toSyncableUser)
    .filter((user): user is SyncableUser => user !== null);
  const rest =
    page.records.length < ATTIO_PAGE_SIZE
      ? []
      : await fetchSyncableUsers(offset + page.records.length);
  return [...syncable, ...rest];
}
 
function toSyncableUser(
  record: AttioRecordFor<typeof AttioObject.User>,
): SyncableUser | null {
  const userId = record.user_id;
  const personRecordId = record.person?.target_record_id;
  if (!userId || !personRecordId) return null;
  return { userId, personRecordId };
}
 
function chunk<T>(items: readonly T[], size: number): T[][] {
  return Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
    items.slice(index * size, (index + 1) * size),
  );
}
 
interface SyncableUser {
  userId: string;
  personRecordId: string;
}