Track product usage
See users metrics in your CRM
- onDaily cron at 6:00 AM ET
- attiopage through every user record
- posthogcount logged_in events per user, last 7 days
- attiowrite logged_in_7d to the linked person
- logreport the sync total
durable — pauses for approvals, survives restarts
Sync your event data from Posthog to Attio and know how your users have been using your product.
Prerequisites
- A Terse project (
terse init) - 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;
}
Apollo
PostHog