Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 10 additions & 70 deletions apps/server/src/telemetry/Identify.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,6 @@
import { Effect, FileSystem, Path, Random, Schema } from "effect";
import * as Crypto from "node:crypto";
import { homedir } from "node:os";
import { Effect, FileSystem, Random } from "effect";
import { ServerConfig } from "../config";

const CodexAuthJsonSchema = Schema.Struct({
tokens: Schema.Struct({
account_id: Schema.String,
}),
});

const ClaudeJsonSchema = Schema.Struct({
userID: Schema.String,
});

class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}

const hash = (value: string) =>
Effect.try({
try: () => Crypto.createHash("sha256").update(value).digest("hex"),
catch: (error) =>
new IdentifyUserError({
message: "Failed to hash identifier",
cause: error,
}),
});

const getCodexAccountId = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const authJsonPath = path.join(homedir(), ".codex", "auth.json");
const authJson = yield* Effect.flatMap(
fileSystem.readFileString(authJsonPath),
Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)),
);

return authJson.tokens.account_id;
});

const getClaudeUserId = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const claudeJsonPath = path.join(homedir(), ".claude.json");
const claudeJson = yield* Effect.flatMap(
fileSystem.readFileString(claudeJsonPath),
Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)),
);

return claudeJson.userID;
});

const upsertAnonymousId = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const { anonymousIdPath } = yield* ServerConfig;
Expand All @@ -68,29 +15,22 @@ const upsertAnonymousId = Effect.gen(function* () {
),
);

return anonymousId;
const trimmed = anonymousId.trim();
if (trimmed.length > 0) return trimmed;

const randomId = yield* Random.nextUUIDv4;
yield* fileSystem.writeFileString(anonymousIdPath, randomId);
return randomId;
});

/**
* getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value.
* 1. ~/.codex/auth.json tokens.account_id
* 2. ~/.claude.json userID
* 3. SYNARA_HOME anonymous-id file (for example ~/.synara/userdata/anonymous-id)
* Returns a random installation-scoped identifier stored in Scient's state directory.
* It never reads or derives identity from connected AI-provider accounts.
*/
export const getTelemetryIdentifier = Effect.gen(function* () {
const codexAccountId = yield* Effect.result(getCodexAccountId);
if (codexAccountId._tag === "Success") {
return yield* hash(codexAccountId.success);
}

const claudeUserId = yield* Effect.result(getClaudeUserId);
if (claudeUserId._tag === "Success") {
return yield* hash(claudeUserId.success);
}

const anonymousId = yield* Effect.result(upsertAnonymousId);
if (anonymousId._tag === "Success") {
return yield* hash(anonymousId.success);
return `installation:${anonymousId.success}`;
}

return null;
Expand Down
43 changes: 31 additions & 12 deletions apps/server/src/telemetry/Layers/AnalyticsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts";
interface RecordedBatchRequest {
readonly path: string;
readonly body: {
readonly batch?: ReadonlyArray<{
readonly event?: string;
readonly schema_version?: number;
readonly source?: string;
readonly events?: ReadonlyArray<{
readonly id?: string;
readonly name?: string;
readonly distinct_id?: string;
readonly occurred_at?: string;
readonly privacy_level?: string;
readonly properties?: {
readonly index?: number;
readonly clientType?: string;
Expand All @@ -25,8 +31,13 @@ interface RecordedBatchRequest {
}

interface RecordedBatchBody {
readonly batch: ReadonlyArray<{
readonly event?: string;
readonly schema_version: number;
readonly source: string;
readonly events: ReadonlyArray<{
readonly id?: string;
readonly name?: string;
readonly distinct_id?: string;
readonly privacy_level?: string;
readonly properties?: {
readonly index?: number;
readonly clientType?: string;
Expand All @@ -46,8 +57,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
const configLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({
SYNARA_TELEMETRY_ENABLED: true,
SYNARA_POSTHOG_KEY: "phc_test_key",
SYNARA_POSTHOG_HOST: "",
SYNARA_TELEMETRY_ENDPOINT: "/v1/events",
SYNARA_TELEMETRY_FLUSH_BATCH_SIZE: 20,
}),
);
Expand Down Expand Up @@ -88,16 +98,16 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {

const batchRequests = capturedRequests.filter(
(request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
Array.isArray(request.body?.batch),
Array.isArray(request.body?.events),
);
assert.equal(batchRequests.length, 3);
assert.equal(
batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"),
batchRequests.every((request) => request.path === "/v1/events"),
true,
);
const deliveredIndexes = batchRequests.flatMap((request) =>
request.body.batch
.filter((event) => event.event === "test.flush.drain")
request.body.events
.filter((event) => event.name === "test.flush.drain")
.map((event) => event.properties?.index)
.filter((index): index is number => typeof index === "number"),
);
Expand All @@ -109,8 +119,17 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
Array.from({ length: 45 }, (_, index) => index),
);
assert.equal(
batchRequests.every((request) =>
request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
batchRequests.every(
(request) =>
request.body.schema_version === 1 &&
request.body.source === "desktop" &&
request.body.events.every(
(event) =>
event.properties?.clientType === "cli-web-client" &&
event.privacy_level === "product" &&
event.distinct_id?.startsWith("installation:") === true &&
typeof event.id === "string",
),
),
true,
);
Expand Down
29 changes: 16 additions & 13 deletions apps/server/src/telemetry/Layers/AnalyticsService.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
/**
* AnalyticsServiceLive - Anonymous PostHog telemetry layer.
* AnalyticsServiceLive - First-party ScientFactory telemetry layer.
*
* Persists a random installation-scoped anonymous id to state dir, buffers
* events in memory, and flushes batches to PostHog over Effect HttpClient.
* events in memory, and flushes batches to the ScientFactory event gateway.
*
* @module AnalyticsServiceLive
*/

import { Config, DateTime, Effect, Layer, Ref } from "effect";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import { randomUUID } from "node:crypto";

import { ServerConfig } from "../../config.ts";
import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
import { getTelemetryIdentifier } from "../Identify.ts";
import { version } from "../../../package.json" with { type: "json" };

interface BufferedAnalyticsEvent {
readonly id: string;
readonly event: string;
readonly properties?: Readonly<Record<string, unknown>>;
readonly capturedAt: string;
}

const TelemetryEnvConfig = Config.all({
posthogKey: Config.string("SYNARA_POSTHOG_KEY").pipe(
Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
),
posthogHost: Config.string("SYNARA_POSTHOG_HOST").pipe(
Config.withDefault("https://us.i.posthog.com"),
endpoint: Config.string("SYNARA_TELEMETRY_ENDPOINT").pipe(
Config.withDefault("https://events.scientfactory.com/v1/events"),
),
enabled: Config.boolean("SYNARA_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
flushBatchSize: Config.number("SYNARA_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
Expand All @@ -49,6 +48,7 @@ const makeAnalyticsService = Effect.gen(function* () {
const appended = [
...current,
{
id: randomUUID(),
event,
...(properties ? { properties } : {}),
capturedAt: DateTime.formatIso(now),
Expand All @@ -75,24 +75,27 @@ const makeAnalyticsService = Effect.gen(function* () {
if (!telemetryConfig.enabled || !identifier) return;

const payload = {
api_key: telemetryConfig.posthogKey,
batch: events.map((event) => ({
event: event.event,
schema_version: 1,
source: "desktop",
sent_at: new Date().toISOString(),
events: events.map((event) => ({
id: event.id,
name: event.event,
distinct_id: identifier,
occurred_at: event.capturedAt,
privacy_level: "product",
properties: {
...event.properties,
$process_person_profile: false,
platform: process.platform,
wsl: process.env.WSL_DISTRO_NAME,
arch: process.arch,
synaraCodeVersion: version,
clientType,
},
timestamp: event.capturedAt,
})),
};

yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
yield* HttpClientRequest.post(telemetryConfig.endpoint).pipe(
HttpClientRequest.bodyJson(payload),
Effect.flatMap(httpClient.execute),
Effect.flatMap(HttpClientResponse.filterStatusOk),
Expand Down
Loading