Skip to content
Draft
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
63 changes: 63 additions & 0 deletions apps/server/src/telemetry/AnalyticsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";
import * as HttpServer from "effect/unstable/http/HttpServer";
import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
Expand Down Expand Up @@ -72,6 +74,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
const runtimeLayer = telemetryLayer.pipe(
Layer.provide(configLayer),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(TestClock.layer()),
);

yield* Effect.gen(function* () {
Expand Down Expand Up @@ -117,4 +120,64 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
);
}),
);

it.effect("periodic flush runs on the TestClock-controlled interval", () =>
Effect.gen(function* () {
const capturedRequests: Array<RecordedBatchRequest> = [];
const receivedRequest = yield* Deferred.make<void>();
const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
prefix: "t3-telemetry-interval-",
});

const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer));
const configLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({
T3CODE_TELEMETRY_ENABLED: true,
T3CODE_POSTHOG_KEY: "phc_test_key",
T3CODE_POSTHOG_HOST: "",
T3CODE_TELEMETRY_FLUSH_BATCH_SIZE: 20,
}),
);
const batchServerLayer = HttpServer.serve(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
if (request.method !== "POST") {
return HttpServerResponse.empty({ status: 404 });
}

const payload = yield* request.json.pipe(
Effect.map((body) => body as RecordedBatchRequest["body"]),
Effect.orElseSucceed(() => null),
);

capturedRequests.push({ path: request.url, body: payload });
yield* Deferred.succeed(receivedRequest, undefined);

return HttpServerResponse.jsonUnsafe({});
}),
);
const runtimeLayer = telemetryLayer.pipe(
Layer.provide(configLayer),
Layer.provideMerge(NodeHttpServer.layerTest),
);

yield* Effect.gen(function* () {
yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped);
const analytics = yield* AnalyticsService.AnalyticsService;

yield* analytics.record("test.flush.interval", { index: 1 });
assert.equal(capturedRequests.length, 0);

yield* TestClock.adjust(AnalyticsService.ANALYTICS_FLUSH_INTERVAL);
yield* Deferred.await(receivedRequest).pipe(Effect.timeout("1 second"), TestClock.withLive);
}).pipe(Effect.provide(runtimeLayer));

const batchRequests = capturedRequests.filter(
(request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
Array.isArray(request.body?.batch),
);
assert.equal(batchRequests.length, 1);
assert.equal(batchRequests[0]?.body.batch[0]?.event, "test.flush.interval");
}),
);
});
5 changes: 4 additions & 1 deletion apps/server/src/telemetry/AnalyticsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/ho
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
Expand All @@ -28,6 +29,8 @@ interface BufferedAnalyticsEvent {
readonly capturedAt: string;
}

export const ANALYTICS_FLUSH_INTERVAL = Duration.seconds(1);

const TelemetryEnvConfig = Config.all({
posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe(
Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
Expand Down Expand Up @@ -172,7 +175,7 @@ export const make = Effect.gen(function* () {
},
);

yield* Effect.forever(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), {
yield* Effect.forever(Effect.sleep(ANALYTICS_FLUSH_INTERVAL).pipe(Effect.flatMap(() => flush)), {
disableYield: true,
}).pipe(Effect.forkScoped);

Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it, describe, expect } from "@effect/vitest";
import { assert, it, describe, expect } from "@effect/vitest";
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 * as PlatformError from "effect/PlatformError";

import * as ServerConfig from "../config.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
Expand Down Expand Up @@ -185,8 +186,9 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
operationPath: resolvedPath,
operation: "realpath-target",
});
expect(error.cause).toBeInstanceOf(Error);
expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT");
assert.instanceOf(error.cause, PlatformError.PlatformError);
assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "NotFound");
assert.equal((error.cause as PlatformError.PlatformError).reason.method, "realPath");
}),
);
});
Expand Down
50 changes: 26 additions & 24 deletions apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,30 +140,32 @@ export const make = Effect.gen(function* () {
relativePath: input.relativePath,
});

const realWorkspaceRoot = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(input.cwd),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: input.cwd,
operation: "realpath-workspace-root",
cause,
}),
});
const realTargetPath = yield* Effect.tryPromise({
try: () => NodeFSP.realpath(target.absolutePath),
catch: (cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: target.absolutePath,
operation: "realpath-target",
cause,
}),
});
const realWorkspaceRoot = yield* fileSystem.realPath(input.cwd).pipe(
Effect.mapError(
(cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: input.cwd,
operation: "realpath-workspace-root",
cause,
}),
),
);
const realTargetPath = yield* fileSystem.realPath(target.absolutePath).pipe(
Effect.mapError(
(cause) =>
new WorkspaceFileSystemOperationError({
workspaceRoot: input.cwd,
relativePath: input.relativePath,
resolvedPath: target.absolutePath,
operationPath: target.absolutePath,
operation: "realpath-target",
cause,
}),
),
);
const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
if (
relativeRealPath.startsWith(`..${path.sep}`) ||
Expand Down
Loading