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
4 changes: 4 additions & 0 deletions apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ const layerCapabilities = Layer.effectContext(
bearerToken: Effect.succeed(Option.none()),
}),
),
// The relay device id already persists in secure storage; analytics only sends its hash.
Context.add(ClientCapabilities.AnalyticsDevice, {
id: storage.loadOrCreateAgentAwarenessDeviceId.pipe(Effect.option),
}),
Context.add(
ClientCapabilities.RelayDeviceIdentity,
ClientCapabilities.RelayDeviceIdentity.of({
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ it.effect("records the calling agent and the agent a tool acted on", () => {
properties: properties ?? {},
}).pipe(Effect.asVoid),
flush: Effect.void,
recordOwners: () => Effect.void,
}),
),
),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/CodexChatGptAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ const makeHarnessFor = Effect.fnUntraced(function* (
analyticsEvents.push({ event, properties });
}),
flush: Effect.void,
recordOwners: () => Effect.void,
});
const auth = yield* makeCodexChatGptAuth({
instanceId,
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/provider/CodexChatGptAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import {
type ProviderInstanceId,
} from "@t3tools/contracts";
import { codexCallbackUrl } from "@t3tools/shared/codexAuthHandoff";
import { hashTelemetryId } from "@t3tools/shared/telemetryId";
import * as Clock from "effect/Clock";
import * as Cause from "effect/Cause";
import * as AnalyticsService from "../telemetry/AnalyticsService.ts";
import { openAiUserId } from "../telemetry/Identify.ts";
import * as Crypto from "effect/Crypto";
import * as Exit from "effect/Exit";
import { codexAuthCallbackPage, codexAuthReturnUrl } from "./CodexAuthCallbackPage.ts";
Expand Down Expand Up @@ -221,6 +223,18 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function*
saved.sessions.find((session) => session.clientId === saved.activeClientId),
),
),
// The OpenAI account is the person, so it marks who owns this install.
Effect.tap((credentials) => {
const userId = Option.flatMap(credentials, (saved) =>
Option.flatMap(Option.fromUndefinedOr(saved.idToken), openAiUserId),
);
return Option.isSome(userId) && Option.isSome(analytics)
? analytics.value.recordOwners(
[{ source: "openai", ownerId: hashTelemetryId(userId.value) }],
"server",
)
: Effect.void;
}),
);
const writeSessions = (saved: typeof Sessions.Type) =>
encodeSessions(saved).pipe(
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/serverRuntimeStartup.autoPull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import { HttpServer } from "effect/http";
Expand All @@ -30,6 +31,7 @@ import { ServerActivation } from "./serverActivation.ts";
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
import * as ServerSettings from "./serverSettings.ts";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";

Expand Down Expand Up @@ -163,6 +165,9 @@ it.effect("parks automatic pull until activation without delaying command readin
),
}),
Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }),
// No provider logins or Connect link, so startup records no owners.
FileSystem.layerNoop({}),
Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }),
NodeCrypto.layer,
Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),
Layer.mock(ExternalLauncher.ExternalLauncher)({}),
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { HttpServer } from "effect/http";
import * as NetAddress from "effect/net/NetAddress";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Ref from "effect/Ref";
Expand Down Expand Up @@ -44,6 +45,7 @@ import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
import { ServerActivation } from "./serverActivation.ts";
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
import * as ServerSettings from "./serverSettings.ts";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as AnalyticsService from "./telemetry/AnalyticsService.ts";

it("uses the canonical Codex model for auto-bootstrap", () => {
Expand Down Expand Up @@ -216,6 +218,9 @@ it.effect.each([
}),
Layer.mock(GitVcsDriver.GitVcsDriver)({}),
Layer.mock(AnalyticsService.AnalyticsService)({}),
// No provider logins or Connect link, so startup records no owners.
FileSystem.layerNoop({}),
Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }),
Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),
Layer.mock(ExternalLauncher.ExternalLauncher)({}),
Layer.mock(HttpServer.HttpServer)({
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
import * as ServerSettings from "./serverSettings.ts";
import { forkParked, forkParkedFiber } from "./serverActivation.ts";
import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
import { getServerOwners } from "./telemetry/Identify.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import {
Expand Down Expand Up @@ -174,6 +175,7 @@ const recordStartupHeartbeat = Effect.gen(function* () {
threadCount,
projectCount,
});
yield* analytics.recordOwners(yield* getServerOwners, "server");
});

export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({
Expand Down
106 changes: 95 additions & 11 deletions apps/server/src/telemetry/AnalyticsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import * as HttpServerResponse from "effect/http/HttpServerResponse";
import * as HostProcess from "@t3tools/shared/HostProcess";

import * as ServerConfig from "../config.ts";
import { getTelemetryIdentifier } from "./Identify.ts";
import { getTelemetryIdentity } from "./Identify.ts";
import * as AnalyticsService from "./AnalyticsService.ts";

interface RecordedBatchRequest {
Expand Down Expand Up @@ -57,7 +57,14 @@ const SentBatch = Schema.fromJsonString(
batch: Schema.Array(
Schema.Struct({
uuid: Schema.String,
properties: Schema.Struct({ telemetryChannel: Schema.String }),
event: Schema.String,
distinct_id: Schema.String,
properties: Schema.Struct({
telemetryChannel: Schema.String,
installId: Schema.optionalKey(Schema.String),
ownerSource: Schema.optionalKey(Schema.String),
observedBy: Schema.optionalKey(Schema.String),
}),
}),
),
}),
Expand All @@ -81,11 +88,26 @@ const respondWithStatus =
(request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status })));

const recordTwentyEvents = Effect.gen(function* () {
const analytics = yield* AnalyticsService.AnalyticsService;
for (let index = 0; index < 20; index += 1) {
yield* analytics.record("test.retry", { index });
}
});

/**
* Records 20 events, then lets ten minutes of background flushes run against
* a client that answers every send with `respond`. Returns the sent batches.
* Runs `record` (20 events by default), then lets ten minutes of background
* flushes run against a client that answers every send with `respond`.
* Returns the sent batches.
*/
const sendForTenMinutes = (respond: Respond) =>
const sendForTenMinutes = (
respond: Respond,
record: Effect.Effect<
void,
never,
AnalyticsService.AnalyticsService | Effect.Services<typeof getTelemetryIdentity>
> = recordTwentyEvents,
) =>
Effect.gen(function* () {
const batches: Array<ReadonlyArray<SentEvent>> = [];
const client = HttpClient.make((request) =>
Expand Down Expand Up @@ -122,10 +144,7 @@ const sendForTenMinutes = (respond: Respond) =>
);

yield* Effect.gen(function* () {
const analytics = yield* AnalyticsService.AnalyticsService;
for (let index = 0; index < 20; index += 1) {
yield* analytics.record("test.retry", { index });
}
yield* record;
for (let second = 0; second < 600; second += 1) {
yield* TestClock.adjust("1 second");
}
Expand Down Expand Up @@ -181,6 +200,71 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
}).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllGlobals()))),
);

it.effect("each owner is sent once per process, and every event carries the install id", () =>
Effect.gen(function* () {
let identity: Effect.Success<typeof getTelemetryIdentity> | null = null;
const batches = yield* sendForTenMinutes(
respondWithStatus(204),
Effect.gen(function* () {
identity = yield* getTelemetryIdentity;
const analytics = yield* AnalyticsService.AnalyticsService;
const connect = { source: "connect", ownerId: "a".repeat(64) } as const;
yield* analytics.recordOwners([connect, connect], "server");
yield* analytics.recordOwners(
[connect, { source: "device", ownerId: "b".repeat(64) }],
"client",
);
}),
);
const events = batches.flat();

assert.deepEqual(
events.map((event) => [
event.event,
event.properties.ownerSource,
event.properties.observedBy,
]),
[
["install.owner_observed", "connect", "server"],
["install.owner_observed", "connect", "client"],
["install.owner_observed", "device", "client"],
],
);
const { distinctId, installId } = identity!;
assert.isString(installId);
assert.isTrue(
events.every(
(event) => event.distinct_id === distinctId && event.properties.installId === installId,
),
);
}),
);

it.effect("client owner reports cannot use up the server's owner cap", () =>
Effect.gen(function* () {
const batches = yield* sendForTenMinutes(
respondWithStatus(204),
Effect.gen(function* () {
const analytics = yield* AnalyticsService.AnalyticsService;
yield* analytics.recordOwners(
Array.from({ length: 1_001 }, (_, index) => ({
source: "device" as const,
ownerId: index.toString(16).padStart(64, "0"),
})),
"client",
);
// The event buffer also holds 1,000, so send the client events first.
yield* analytics.flush;
yield* analytics.recordOwners([{ source: "connect", ownerId: "a".repeat(64) }], "server");
}),
);
const owners = batches.flat().map((event) => event.properties.observedBy);

assert.equal(owners.filter((observedBy) => observedBy === "client").length, 1_000);
assert.equal(owners.filter((observedBy) => observedBy === "server").length, 1);
}),
);

it.effect("flush drains all buffered events across multiple batches", () =>
Effect.gen(function* () {
const capturedRequests: Array<RecordedBatchRequest> = [];
Expand Down Expand Up @@ -227,8 +311,8 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {

yield* Effect.gen(function* () {
yield* Layer.launch(layerBatchServer).pipe(Effect.forkScoped);
const telemetryIdentifier = yield* getTelemetryIdentifier;
assert.equal(telemetryIdentifier !== null, true);
const { distinctId } = yield* getTelemetryIdentity;
assert.equal(distinctId !== null, true);
const analytics = yield* AnalyticsService.AnalyticsService;

for (let index = 0; index < 45; index += 1) {
Expand Down
Loading
Loading