diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index d69bab32feb..3a2cca2397c 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -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"; @@ -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* () { @@ -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 = []; + const receivedRequest = yield* Deferred.make(); + 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"); + }), + ); }); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 5fdc7bdeb19..c5625892312 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -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"; @@ -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"), @@ -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); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..a1bb94980cc 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -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"; @@ -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"); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..825212f86fa 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -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}`) ||