diff --git a/.changeset/20260827234700-session-metrics-api.md b/.changeset/20260827234700-session-metrics-api.md new file mode 100644 index 000000000..0ebf0d7fd --- /dev/null +++ b/.changeset/20260827234700-session-metrics-api.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-core': patch +'@truefoundry/trueforge': patch +--- + +Add caller-scoped session metrics meters, charts, and chart-data under `/internal/metrics` via a server-owned `ISessionMetricsStore`. diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index 5d269a904..18bd31b3f 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -1410,6 +1410,39 @@ ], "type": "object" }, + "GetSessionMetricsChartDataResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMetricsChartDataResponse" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "GetSessionMetricsChartResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMetricsChartResponse" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "GetSessionMetricsMeterResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMetricsMeterResponse" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "GetSessionResponse": { "properties": { "data": { @@ -2169,6 +2202,19 @@ ], "type": "object" }, + "MetricsUnit": { + "enum": [ + "count", + "$", + "ms" + ], + "type": "string", + "x-fern-enum": { + "$": { + "name": "USD" + } + } + }, "Model": { "properties": { "name": { @@ -3108,6 +3154,9 @@ "description": "Unique session id.", "type": "string" }, + "metrics": { + "$ref": "#/components/schemas/SessionMetrics" + }, "title": { "description": "Optional human-readable title; null until set.", "type": [ @@ -3126,7 +3175,8 @@ "title", "created_by", "created_at", - "updated_at" + "updated_at", + "metrics" ], "type": "object" }, @@ -3290,6 +3340,228 @@ ], "type": "object" }, + "SessionMetrics": { + "additionalProperties": false, + "description": "Rolled-up cost, duration, and turn counters for a session.", + "properties": { + "total_cost_in_usd": { + "minimum": 0, + "type": "number" + }, + "total_duration_ms": { + "minimum": 0, + "type": "integer" + }, + "total_turns": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "total_cost_in_usd", + "total_duration_ms", + "total_turns" + ], + "type": "object" + }, + "SessionMetricsChart": { + "additionalProperties": false, + "properties": { + "chart_type": { + "enum": [ + "line" + ], + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/SessionMetricsChartName" + } + }, + "required": [ + "name", + "display_name", + "description", + "chart_type" + ], + "type": "object" + }, + "SessionMetricsChartDataResponse": { + "additionalProperties": false, + "properties": { + "graphs": { + "items": { + "$ref": "#/components/schemas/SessionMetricsGraph" + }, + "type": "array" + }, + "step": { + "type": "string" + } + }, + "required": [ + "step", + "graphs" + ], + "type": "object" + }, + "SessionMetricsChartName": { + "description": "Session metrics chart to return.", + "enum": [ + "sessions_over_time", + "sessions_cost_over_time", + "turns_over_time" + ], + "type": "string" + }, + "SessionMetricsChartResponse": { + "additionalProperties": false, + "properties": { + "charts": { + "items": { + "$ref": "#/components/schemas/SessionMetricsChart" + }, + "type": "array" + } + }, + "required": [ + "charts" + ], + "type": "object" + }, + "SessionMetricsGraph": { + "additionalProperties": false, + "properties": { + "chart_type": { + "enum": [ + "line" + ], + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "graph_lines": { + "items": { + "$ref": "#/components/schemas/SessionMetricsGraphLine" + }, + "type": "array" + }, + "name": { + "$ref": "#/components/schemas/SessionMetricsChartName" + }, + "unit": { + "$ref": "#/components/schemas/MetricsUnit" + } + }, + "required": [ + "name", + "display_name", + "description", + "unit", + "chart_type", + "graph_lines" + ], + "type": "object" + }, + "SessionMetricsGraphLine": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "values": { + "items": { + "$ref": "#/components/schemas/SessionMetricsPoint" + }, + "type": "array" + } + }, + "required": [ + "name", + "values" + ], + "type": "object" + }, + "SessionMetricsMeter": { + "additionalProperties": false, + "properties": { + "aggregate_value": { + "minimum": 0, + "type": "number" + }, + "description": { + "type": "string" + }, + "name": { + "enum": [ + "total_sessions", + "total_cost_in_usd", + "total_turns", + "cost_per_session_in_usd", + "avg_turns_per_session", + "min_turns_per_session", + "max_turns_per_session", + "median_turns_per_session", + "min_session_duration_ms", + "max_session_duration_ms", + "median_session_duration_ms", + "p95_session_duration_ms" + ], + "type": "string" + }, + "unit": { + "$ref": "#/components/schemas/MetricsUnit" + } + }, + "required": [ + "name", + "aggregate_value", + "description", + "unit" + ], + "type": "object" + }, + "SessionMetricsMeterResponse": { + "additionalProperties": false, + "properties": { + "meters": { + "items": { + "$ref": "#/components/schemas/SessionMetricsMeter" + }, + "type": "array" + } + }, + "required": [ + "meters" + ], + "type": "object" + }, + "SessionMetricsPoint": { + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string" + }, + "value": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "timestamp", + "value" + ], + "type": "object" + }, "SettingsCapability": { "properties": { "enabled": { @@ -7680,6 +7952,185 @@ "x-fern-sdk-method-name": "list" } }, + "/internal/metrics/charts": { + "get": { + "description": "List available session metric charts.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSessionMetricsChartResponse" + } + } + }, + "description": "Available session metric charts." + } + }, + "summary": "Get session metrics charts", + "tags": [ + "Internal" + ], + "x-fern-sdk-group-name": [ + "internal", + "metrics" + ], + "x-fern-sdk-method-name": "list_charts" + } + }, + "/internal/metrics/charts-data": { + "get": { + "description": "Return one chart for the caller's sessions on a named agent over an inclusive creation-time window. Uses hourly buckets for windows up to 24 hours and daily UTC buckets otherwise.", + "parameters": [ + { + "description": "Named agent identifier.", + "in": "query", + "name": "agent_id", + "required": true, + "schema": { + "description": "Named agent identifier.", + "maxLength": 64, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Inclusive lower bound on session `created_at`.", + "in": "query", + "name": "start_timestamp", + "required": true, + "schema": { + "description": "Inclusive lower bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + }, + { + "description": "Inclusive upper bound on session `created_at`.", + "in": "query", + "name": "end_timestamp", + "required": true, + "schema": { + "description": "Inclusive upper bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + }, + { + "description": "Session metrics chart to return.", + "in": "query", + "name": "chart_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/SessionMetricsChartName" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSessionMetricsChartDataResponse" + } + } + }, + "description": "Zero-filled time series for one chart." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid timestamps or a window longer than 30 days." + } + }, + "summary": "Get session metrics chart data", + "tags": [ + "Internal" + ], + "x-fern-sdk-group-name": [ + "internal", + "metrics" + ], + "x-fern-sdk-method-name": "get_chart_data" + } + }, + "/internal/metrics/meters": { + "get": { + "description": "Aggregate the caller's session meters for a named agent over an inclusive creation-time window.", + "parameters": [ + { + "description": "Named agent identifier.", + "in": "query", + "name": "agent_id", + "required": true, + "schema": { + "description": "Named agent identifier.", + "maxLength": 64, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Inclusive lower bound on session `created_at`.", + "in": "query", + "name": "start_timestamp", + "required": true, + "schema": { + "description": "Inclusive lower bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + }, + { + "description": "Inclusive upper bound on session `created_at`.", + "in": "query", + "name": "end_timestamp", + "required": true, + "schema": { + "description": "Inclusive upper bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSessionMetricsMeterResponse" + } + } + }, + "description": "Session metric meters." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid timestamps or a window longer than 30 days." + } + }, + "summary": "Get session metrics meters", + "tags": [ + "Internal" + ], + "x-fern-sdk-group-name": [ + "internal", + "metrics" + ], + "x-fern-sdk-method-name": "get_meters" + } + }, "/internal/sessions/get-or-create-by-external-id": { "post": { "description": "Idempotent get-or-create: returns the existing session for this `external_id`, or creates one", diff --git a/docs/openapi.json b/docs/openapi.json index 5d269a904..18bd31b3f 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1410,6 +1410,39 @@ ], "type": "object" }, + "GetSessionMetricsChartDataResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMetricsChartDataResponse" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "GetSessionMetricsChartResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMetricsChartResponse" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "GetSessionMetricsMeterResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SessionMetricsMeterResponse" + } + }, + "required": [ + "data" + ], + "type": "object" + }, "GetSessionResponse": { "properties": { "data": { @@ -2169,6 +2202,19 @@ ], "type": "object" }, + "MetricsUnit": { + "enum": [ + "count", + "$", + "ms" + ], + "type": "string", + "x-fern-enum": { + "$": { + "name": "USD" + } + } + }, "Model": { "properties": { "name": { @@ -3108,6 +3154,9 @@ "description": "Unique session id.", "type": "string" }, + "metrics": { + "$ref": "#/components/schemas/SessionMetrics" + }, "title": { "description": "Optional human-readable title; null until set.", "type": [ @@ -3126,7 +3175,8 @@ "title", "created_by", "created_at", - "updated_at" + "updated_at", + "metrics" ], "type": "object" }, @@ -3290,6 +3340,228 @@ ], "type": "object" }, + "SessionMetrics": { + "additionalProperties": false, + "description": "Rolled-up cost, duration, and turn counters for a session.", + "properties": { + "total_cost_in_usd": { + "minimum": 0, + "type": "number" + }, + "total_duration_ms": { + "minimum": 0, + "type": "integer" + }, + "total_turns": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "total_cost_in_usd", + "total_duration_ms", + "total_turns" + ], + "type": "object" + }, + "SessionMetricsChart": { + "additionalProperties": false, + "properties": { + "chart_type": { + "enum": [ + "line" + ], + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "name": { + "$ref": "#/components/schemas/SessionMetricsChartName" + } + }, + "required": [ + "name", + "display_name", + "description", + "chart_type" + ], + "type": "object" + }, + "SessionMetricsChartDataResponse": { + "additionalProperties": false, + "properties": { + "graphs": { + "items": { + "$ref": "#/components/schemas/SessionMetricsGraph" + }, + "type": "array" + }, + "step": { + "type": "string" + } + }, + "required": [ + "step", + "graphs" + ], + "type": "object" + }, + "SessionMetricsChartName": { + "description": "Session metrics chart to return.", + "enum": [ + "sessions_over_time", + "sessions_cost_over_time", + "turns_over_time" + ], + "type": "string" + }, + "SessionMetricsChartResponse": { + "additionalProperties": false, + "properties": { + "charts": { + "items": { + "$ref": "#/components/schemas/SessionMetricsChart" + }, + "type": "array" + } + }, + "required": [ + "charts" + ], + "type": "object" + }, + "SessionMetricsGraph": { + "additionalProperties": false, + "properties": { + "chart_type": { + "enum": [ + "line" + ], + "type": "string" + }, + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "graph_lines": { + "items": { + "$ref": "#/components/schemas/SessionMetricsGraphLine" + }, + "type": "array" + }, + "name": { + "$ref": "#/components/schemas/SessionMetricsChartName" + }, + "unit": { + "$ref": "#/components/schemas/MetricsUnit" + } + }, + "required": [ + "name", + "display_name", + "description", + "unit", + "chart_type", + "graph_lines" + ], + "type": "object" + }, + "SessionMetricsGraphLine": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "values": { + "items": { + "$ref": "#/components/schemas/SessionMetricsPoint" + }, + "type": "array" + } + }, + "required": [ + "name", + "values" + ], + "type": "object" + }, + "SessionMetricsMeter": { + "additionalProperties": false, + "properties": { + "aggregate_value": { + "minimum": 0, + "type": "number" + }, + "description": { + "type": "string" + }, + "name": { + "enum": [ + "total_sessions", + "total_cost_in_usd", + "total_turns", + "cost_per_session_in_usd", + "avg_turns_per_session", + "min_turns_per_session", + "max_turns_per_session", + "median_turns_per_session", + "min_session_duration_ms", + "max_session_duration_ms", + "median_session_duration_ms", + "p95_session_duration_ms" + ], + "type": "string" + }, + "unit": { + "$ref": "#/components/schemas/MetricsUnit" + } + }, + "required": [ + "name", + "aggregate_value", + "description", + "unit" + ], + "type": "object" + }, + "SessionMetricsMeterResponse": { + "additionalProperties": false, + "properties": { + "meters": { + "items": { + "$ref": "#/components/schemas/SessionMetricsMeter" + }, + "type": "array" + } + }, + "required": [ + "meters" + ], + "type": "object" + }, + "SessionMetricsPoint": { + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string" + }, + "value": { + "minimum": 0, + "type": "number" + } + }, + "required": [ + "timestamp", + "value" + ], + "type": "object" + }, "SettingsCapability": { "properties": { "enabled": { @@ -7680,6 +7952,185 @@ "x-fern-sdk-method-name": "list" } }, + "/internal/metrics/charts": { + "get": { + "description": "List available session metric charts.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSessionMetricsChartResponse" + } + } + }, + "description": "Available session metric charts." + } + }, + "summary": "Get session metrics charts", + "tags": [ + "Internal" + ], + "x-fern-sdk-group-name": [ + "internal", + "metrics" + ], + "x-fern-sdk-method-name": "list_charts" + } + }, + "/internal/metrics/charts-data": { + "get": { + "description": "Return one chart for the caller's sessions on a named agent over an inclusive creation-time window. Uses hourly buckets for windows up to 24 hours and daily UTC buckets otherwise.", + "parameters": [ + { + "description": "Named agent identifier.", + "in": "query", + "name": "agent_id", + "required": true, + "schema": { + "description": "Named agent identifier.", + "maxLength": 64, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Inclusive lower bound on session `created_at`.", + "in": "query", + "name": "start_timestamp", + "required": true, + "schema": { + "description": "Inclusive lower bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + }, + { + "description": "Inclusive upper bound on session `created_at`.", + "in": "query", + "name": "end_timestamp", + "required": true, + "schema": { + "description": "Inclusive upper bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + }, + { + "description": "Session metrics chart to return.", + "in": "query", + "name": "chart_name", + "required": true, + "schema": { + "$ref": "#/components/schemas/SessionMetricsChartName" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSessionMetricsChartDataResponse" + } + } + }, + "description": "Zero-filled time series for one chart." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid timestamps or a window longer than 30 days." + } + }, + "summary": "Get session metrics chart data", + "tags": [ + "Internal" + ], + "x-fern-sdk-group-name": [ + "internal", + "metrics" + ], + "x-fern-sdk-method-name": "get_chart_data" + } + }, + "/internal/metrics/meters": { + "get": { + "description": "Aggregate the caller's session meters for a named agent over an inclusive creation-time window.", + "parameters": [ + { + "description": "Named agent identifier.", + "in": "query", + "name": "agent_id", + "required": true, + "schema": { + "description": "Named agent identifier.", + "maxLength": 64, + "minLength": 1, + "type": "string" + } + }, + { + "description": "Inclusive lower bound on session `created_at`.", + "in": "query", + "name": "start_timestamp", + "required": true, + "schema": { + "description": "Inclusive lower bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + }, + { + "description": "Inclusive upper bound on session `created_at`.", + "in": "query", + "name": "end_timestamp", + "required": true, + "schema": { + "description": "Inclusive upper bound on session `created_at`.", + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSessionMetricsMeterResponse" + } + } + }, + "description": "Session metric meters." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestErrorResponse" + } + } + }, + "description": "Invalid timestamps or a window longer than 30 days." + } + }, + "summary": "Get session metrics meters", + "tags": [ + "Internal" + ], + "x-fern-sdk-group-name": [ + "internal", + "metrics" + ], + "x-fern-sdk-method-name": "get_meters" + } + }, "/internal/sessions/get-or-create-by-external-id": { "post": { "description": "Idempotent get-or-create: returns the existing session for this `external_id`, or creates one", diff --git a/packages/trueforge-core/src/agent-session/schemas/session.ts b/packages/trueforge-core/src/agent-session/schemas/session.ts index a25eb0679..087cd0fd0 100644 --- a/packages/trueforge-core/src/agent-session/schemas/session.ts +++ b/packages/trueforge-core/src/agent-session/schemas/session.ts @@ -11,7 +11,9 @@ export const SessionMetricsSchema = z total_duration_ms: z.number().int().nonnegative(), total_turns: z.number().int().nonnegative(), }) - .strict(); + .strict() + .describe('Rolled-up cost, duration, and turn counters for a session.') + .openapi('SessionMetrics'); export const SessionAgentReferenceSchema = z .object({ @@ -48,6 +50,7 @@ export const SessionSchema = z created_by: z.string().describe('Caller identity that created the session (immutable).'), created_at: z.string().describe('ISO 8601 creation timestamp.'), updated_at: z.string().describe('ISO 8601 last-update timestamp.'), + metrics: SessionMetricsSchema, }) .openapi('Session'); diff --git a/packages/trueforge-sdk/package.json b/packages/trueforge-sdk/package.json index fe9ebec2d..9ffd170ae 100644 --- a/packages/trueforge-sdk/package.json +++ b/packages/trueforge-sdk/package.json @@ -194,6 +194,17 @@ }, "default": "./dist/cjs/api/resources/internal/resources/agents/exports.js" }, + "./internal/metrics": { + "import": { + "types": "./dist/esm/api/resources/internal/resources/metrics/exports.d.mts", + "default": "./dist/esm/api/resources/internal/resources/metrics/exports.mjs" + }, + "require": { + "types": "./dist/cjs/api/resources/internal/resources/metrics/exports.d.ts", + "default": "./dist/cjs/api/resources/internal/resources/metrics/exports.js" + }, + "default": "./dist/cjs/api/resources/internal/resources/metrics/exports.js" + }, "./internal/sessions": { "import": { "types": "./dist/esm/api/resources/internal/resources/sessions/exports.d.mts", diff --git a/packages/trueforge-sdk/reference.md b/packages/trueforge-sdk/reference.md index 1cc749687..65bd6ed2f 100644 --- a/packages/trueforge-sdk/reference.md +++ b/packages/trueforge-sdk/reference.md @@ -2496,6 +2496,197 @@ await client.internal.agents.getCodeSnippets("agent_id"); + + + + +## Internal Metrics +
client.internal.metrics.listCharts() -> TrueForge.GetSessionMetricsChartResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +List available session metric charts. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.internal.metrics.listCharts(); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**requestOptions:** `MetricsClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.internal.metrics.getChartData({ ...params }) -> TrueForge.GetSessionMetricsChartDataResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Return one chart for the caller's sessions on a named agent over an inclusive creation-time window. Uses hourly buckets for windows up to 24 hours and daily UTC buckets otherwise. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.internal.metrics.getChartData({ + agentId: "agent_id", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + chartName: "sessions_over_time" +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TrueForge.internal.GetChartDataMetricsRequest` + +
+
+ +
+
+ +**requestOptions:** `MetricsClient.RequestOptions` + +
+
+
+
+ + +
+
+
+ +
client.internal.metrics.getMeters({ ...params }) -> TrueForge.GetSessionMetricsMeterResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Aggregate the caller's session meters for a named agent over an inclusive creation-time window. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```typescript +await client.internal.metrics.getMeters({ + agentId: "agent_id", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z") +}); + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `TrueForge.internal.GetMetersMetricsRequest` + +
+
+ +
+
+ +**requestOptions:** `MetricsClient.RequestOptions` + +
+
+
+
+ +
diff --git a/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts b/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts index 6022e3c4d..4773e2b34 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/client/Client.ts @@ -3,6 +3,7 @@ import type { BaseClientOptions } from "../../../../BaseClient.js"; import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "../../../../BaseClient.js"; import { AgentsClient } from "../resources/agents/client/Client.js"; +import { MetricsClient } from "../resources/metrics/client/Client.js"; import { SessionsClient } from "../resources/sessions/client/Client.js"; export declare namespace InternalClient { @@ -12,6 +13,7 @@ export declare namespace InternalClient { export class InternalClient { protected readonly _options: NormalizedClientOptionsWithAuth; protected _agents: AgentsClient | undefined; + protected _metrics: MetricsClient | undefined; protected _sessions: SessionsClient | undefined; constructor(options: InternalClient.Options) { @@ -22,6 +24,10 @@ export class InternalClient { return (this._agents ??= new AgentsClient(this._options)); } + public get metrics(): MetricsClient { + return (this._metrics ??= new MetricsClient(this._options)); + } + public get sessions(): SessionsClient { return (this._sessions ??= new SessionsClient(this._options)); } diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts index 1d299c7a3..d006bfdd8 100644 --- a/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/index.ts @@ -1,3 +1,5 @@ export * as agents from "./agents/index.js"; +export * from "./metrics/client/requests/index.js"; +export * as metrics from "./metrics/index.js"; export * from "./sessions/client/requests/index.js"; export * as sessions from "./sessions/index.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/Client.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/Client.ts new file mode 100644 index 000000000..96ef78095 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/Client.ts @@ -0,0 +1,314 @@ +// This file was auto-generated by Fern from our API Definition. + +import type { BaseClientOptions, BaseRequestOptions } from "../../../../../../BaseClient.js"; +import { type NormalizedClientOptionsWithAuth, normalizeClientOptionsWithAuth } from "../../../../../../BaseClient.js"; +import { mergeHeaders } from "../../../../../../core/headers.js"; +import * as core from "../../../../../../core/index.js"; +import { handleNonStatusCodeError } from "../../../../../../errors/handleNonStatusCodeError.js"; +import * as errors from "../../../../../../errors/index.js"; +import * as serializers from "../../../../../../serialization/index.js"; +import * as TrueForge from "../../../../../index.js"; + +export declare namespace MetricsClient { + export type Options = BaseClientOptions; + + export interface RequestOptions extends BaseRequestOptions {} +} + +export class MetricsClient { + protected readonly _options: NormalizedClientOptionsWithAuth; + + constructor(options: MetricsClient.Options) { + this._options = normalizeClientOptionsWithAuth(options); + } + + /** + * List available session metric charts. + * + * @param {MetricsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.internal.metrics.listCharts() + */ + public listCharts( + requestOptions?: MetricsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__listCharts(requestOptions)); + } + + private async __listCharts( + requestOptions?: MetricsClient.RequestOptions, + ): Promise> { + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "internal/metrics/charts", + ), + method: "GET", + headers: _headers, + queryString: core.url.queryBuilder().mergeAdditional(requestOptions?.queryParams).build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: serializers.GetSessionMetricsChartResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/internal/metrics/charts"); + } + + /** + * Return one chart for the caller's sessions on a named agent over an inclusive creation-time window. Uses hourly buckets for windows up to 24 hours and daily UTC buckets otherwise. + * + * @param {TrueForge.internal.GetChartDataMetricsRequest} request + * @param {MetricsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link TrueForge.BadRequestError} + * @throws {@link TrueForge.NotFoundError} + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.internal.metrics.getChartData({ + * agentId: "agent_id", + * startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + * endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + * chartName: "sessions_over_time" + * }) + */ + public getChartData( + request: TrueForge.internal.GetChartDataMetricsRequest, + requestOptions?: MetricsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__getChartData(request, requestOptions)); + } + + private async __getChartData( + request: TrueForge.internal.GetChartDataMetricsRequest, + requestOptions?: MetricsClient.RequestOptions, + ): Promise> { + const { agentId, startTimestamp, endTimestamp, chartName } = request; + const _queryParams: Record = { + agent_id: agentId, + start_timestamp: startTimestamp.toISOString(), + end_timestamp: endTimestamp.toISOString(), + chart_name: serializers.SessionMetricsChartName.jsonOrThrow(chartName, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + omitUndefined: true, + }), + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "internal/metrics/charts-data", + ), + method: "GET", + headers: _headers, + queryString: core.url + .queryBuilder() + .addMany(_queryParams) + .mergeAdditional(requestOptions?.queryParams) + .build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: serializers.GetSessionMetricsChartDataResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 400: + throw new TrueForge.BadRequestError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + case 404: + throw new TrueForge.NotFoundError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + default: + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/internal/metrics/charts-data"); + } + + /** + * Aggregate the caller's session meters for a named agent over an inclusive creation-time window. + * + * @param {TrueForge.internal.GetMetersMetricsRequest} request + * @param {MetricsClient.RequestOptions} requestOptions - Request-specific configuration. + * + * @throws {@link TrueForge.BadRequestError} + * @throws {@link TrueForge.NotFoundError} + * @throws {@link errors.TrueForgeError} + * @throws {@link errors.TrueForgeTimeoutError} + * + * @example + * await client.internal.metrics.getMeters({ + * agentId: "agent_id", + * startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + * endTimestamp: new Date("2024-01-15T09:30:00.000Z") + * }) + */ + public getMeters( + request: TrueForge.internal.GetMetersMetricsRequest, + requestOptions?: MetricsClient.RequestOptions, + ): core.HttpResponsePromise { + return core.HttpResponsePromise.fromPromise(this.__getMeters(request, requestOptions)); + } + + private async __getMeters( + request: TrueForge.internal.GetMetersMetricsRequest, + requestOptions?: MetricsClient.RequestOptions, + ): Promise> { + const { agentId, startTimestamp, endTimestamp } = request; + const _queryParams: Record = { + agent_id: agentId, + start_timestamp: startTimestamp.toISOString(), + end_timestamp: endTimestamp.toISOString(), + }; + const _authRequest: core.AuthRequest = await this._options.authProvider.getAuthRequest(); + const _headers: core.Fetcher.Args["headers"] = mergeHeaders( + _authRequest.headers, + this._options?.headers, + requestOptions?.headers, + ); + const _response = await (this._options.fetcher ?? core.fetcher)({ + url: core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + (await core.Supplier.get(this._options.environment)), + "internal/metrics/meters", + ), + method: "GET", + headers: _headers, + queryString: core.url + .queryBuilder() + .addMany(_queryParams) + .mergeAdditional(requestOptions?.queryParams) + .build(), + timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000, + maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries, + abortSignal: requestOptions?.abortSignal, + fetchFn: this._options?.fetch, + logging: this._options.logging, + }); + if (_response.ok) { + return { + data: serializers.GetSessionMetricsMeterResponse.parseOrThrow(_response.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + rawResponse: _response.rawResponse, + }; + } + + if (_response.error.reason === "status-code") { + switch (_response.error.statusCode) { + case 400: + throw new TrueForge.BadRequestError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + case 404: + throw new TrueForge.NotFoundError( + serializers.RequestErrorResponse.parseOrThrow(_response.error.body, { + unrecognizedObjectKeys: "passthrough", + allowUnrecognizedUnionMembers: true, + allowUnrecognizedEnumValues: true, + skipValidation: true, + breadcrumbsPrefix: ["response"], + }), + _response.rawResponse, + ); + default: + throw new errors.TrueForgeError({ + statusCode: _response.error.statusCode, + body: _response.error.body, + rawResponse: _response.rawResponse, + }); + } + } + + return handleNonStatusCodeError(_response.error, _response.rawResponse, "GET", "/internal/metrics/meters"); + } +} diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/index.ts new file mode 100644 index 000000000..195f9aa8a --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/index.ts @@ -0,0 +1 @@ +export * from "./requests/index.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/GetChartDataMetricsRequest.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/GetChartDataMetricsRequest.ts new file mode 100644 index 000000000..42ba31d03 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/GetChartDataMetricsRequest.ts @@ -0,0 +1,23 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../../../../../index.js"; + +/** + * @example + * { + * agentId: "agent_id", + * startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + * endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + * chartName: "sessions_over_time" + * } + */ +export interface GetChartDataMetricsRequest { + /** Named agent identifier. */ + agentId: string; + /** Inclusive lower bound on session `created_at`. */ + startTimestamp: Date; + /** Inclusive upper bound on session `created_at`. */ + endTimestamp: Date; + /** Session metrics chart to return. */ + chartName: TrueForge.SessionMetricsChartName; +} diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/GetMetersMetricsRequest.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/GetMetersMetricsRequest.ts new file mode 100644 index 000000000..850f4a148 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/GetMetersMetricsRequest.ts @@ -0,0 +1,18 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * @example + * { + * agentId: "agent_id", + * startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + * endTimestamp: new Date("2024-01-15T09:30:00.000Z") + * } + */ +export interface GetMetersMetricsRequest { + /** Named agent identifier. */ + agentId: string; + /** Inclusive lower bound on session `created_at`. */ + startTimestamp: Date; + /** Inclusive upper bound on session `created_at`. */ + endTimestamp: Date; +} diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/index.ts new file mode 100644 index 000000000..1f722bb50 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/client/requests/index.ts @@ -0,0 +1,2 @@ +export type { GetChartDataMetricsRequest } from "./GetChartDataMetricsRequest.js"; +export type { GetMetersMetricsRequest } from "./GetMetersMetricsRequest.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/exports.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/exports.ts new file mode 100644 index 000000000..27d555284 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/exports.ts @@ -0,0 +1,4 @@ +// This file was auto-generated by Fern from our API Definition. + +export { MetricsClient } from "./client/Client.js"; +export * from "./client/index.js"; diff --git a/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/index.ts b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/index.ts new file mode 100644 index 000000000..914b8c3c7 --- /dev/null +++ b/packages/trueforge-sdk/src/api/resources/internal/resources/metrics/index.ts @@ -0,0 +1 @@ +export * from "./client/index.js"; diff --git a/packages/trueforge-sdk/src/api/types/GetSessionMetricsChartDataResponse.ts b/packages/trueforge-sdk/src/api/types/GetSessionMetricsChartDataResponse.ts new file mode 100644 index 000000000..2628cc74b --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/GetSessionMetricsChartDataResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface GetSessionMetricsChartDataResponse { + data: TrueForge.SessionMetricsChartDataResponse; +} diff --git a/packages/trueforge-sdk/src/api/types/GetSessionMetricsChartResponse.ts b/packages/trueforge-sdk/src/api/types/GetSessionMetricsChartResponse.ts new file mode 100644 index 000000000..ab553239d --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/GetSessionMetricsChartResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface GetSessionMetricsChartResponse { + data: TrueForge.SessionMetricsChartResponse; +} diff --git a/packages/trueforge-sdk/src/api/types/GetSessionMetricsMeterResponse.ts b/packages/trueforge-sdk/src/api/types/GetSessionMetricsMeterResponse.ts new file mode 100644 index 000000000..61fcd53dd --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/GetSessionMetricsMeterResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface GetSessionMetricsMeterResponse { + data: TrueForge.SessionMetricsMeterResponse; +} diff --git a/packages/trueforge-sdk/src/api/types/Session.ts b/packages/trueforge-sdk/src/api/types/Session.ts index 99b15ce05..cea66fa22 100644 --- a/packages/trueforge-sdk/src/api/types/Session.ts +++ b/packages/trueforge-sdk/src/api/types/Session.ts @@ -10,6 +10,7 @@ export interface Session { createdBy: string; /** Unique session id. */ id: string; + metrics: TrueForge.SessionMetrics; /** Optional human-readable title; null until set. */ title: string | null; /** ISO 8601 last-update timestamp. */ diff --git a/packages/trueforge-sdk/src/api/types/SessionMetrics.ts b/packages/trueforge-sdk/src/api/types/SessionMetrics.ts new file mode 100644 index 000000000..2c2d1c18b --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetrics.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +/** + * Rolled-up cost, duration, and turn counters for a session. + */ +export interface SessionMetrics { + totalCostInUsd: number; + totalDurationMs: number; + totalTurns: number; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsChart.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsChart.ts new file mode 100644 index 000000000..15934e49b --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsChart.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SessionMetricsChart { + chartType: "line"; + description: string; + displayName: string; + name: TrueForge.SessionMetricsChartName; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsChartDataResponse.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsChartDataResponse.ts new file mode 100644 index 000000000..0c4b1e4af --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsChartDataResponse.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SessionMetricsChartDataResponse { + graphs: TrueForge.SessionMetricsGraph[]; + step: string; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsChartName.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsChartName.ts new file mode 100644 index 000000000..ff098760d --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsChartName.ts @@ -0,0 +1,9 @@ +// This file was auto-generated by Fern from our API Definition. + +/** Session metrics chart to return. */ +export const SessionMetricsChartName = { + SessionsOverTime: "sessions_over_time", + SessionsCostOverTime: "sessions_cost_over_time", + TurnsOverTime: "turns_over_time", +} as const; +export type SessionMetricsChartName = (typeof SessionMetricsChartName)[keyof typeof SessionMetricsChartName]; diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsChartResponse.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsChartResponse.ts new file mode 100644 index 000000000..85507b2fd --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsChartResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SessionMetricsChartResponse { + charts: TrueForge.SessionMetricsChart[]; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsGraph.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsGraph.ts new file mode 100644 index 000000000..c3206a500 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsGraph.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SessionMetricsGraph { + chartType: "line"; + description: string; + displayName: string; + graphLines: TrueForge.SessionMetricsGraphLine[]; + name: TrueForge.SessionMetricsChartName; + unit: TrueForge.SessionMetricsGraphUnit; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsGraphLine.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsGraphLine.ts new file mode 100644 index 000000000..47e98ad29 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsGraphLine.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SessionMetricsGraphLine { + name: string; + values: TrueForge.SessionMetricsPoint[]; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsGraphUnit.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsGraphUnit.ts new file mode 100644 index 000000000..dc7988e04 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsGraphUnit.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +export const SessionMetricsGraphUnit = { + Count: "count", + Usd: "$", +} as const; +export type SessionMetricsGraphUnit = (typeof SessionMetricsGraphUnit)[keyof typeof SessionMetricsGraphUnit]; diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsMeter.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsMeter.ts new file mode 100644 index 000000000..b462144c7 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsMeter.ts @@ -0,0 +1,10 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SessionMetricsMeter { + aggregateValue: number; + description: string; + name: TrueForge.SessionMetricsMeterName; + unit: TrueForge.SessionMetricsMeterUnit; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsMeterName.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsMeterName.ts new file mode 100644 index 000000000..4bc7289cf --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsMeterName.ts @@ -0,0 +1,17 @@ +// This file was auto-generated by Fern from our API Definition. + +export const SessionMetricsMeterName = { + TotalSessions: "total_sessions", + TotalCostInUsd: "total_cost_in_usd", + TotalTurns: "total_turns", + CostPerSessionInUsd: "cost_per_session_in_usd", + AvgTurnsPerSession: "avg_turns_per_session", + MinTurnsPerSession: "min_turns_per_session", + MaxTurnsPerSession: "max_turns_per_session", + MedianTurnsPerSession: "median_turns_per_session", + MinSessionDurationMs: "min_session_duration_ms", + MaxSessionDurationMs: "max_session_duration_ms", + MedianSessionDurationMs: "median_session_duration_ms", + P95SessionDurationMs: "p95_session_duration_ms", +} as const; +export type SessionMetricsMeterName = (typeof SessionMetricsMeterName)[keyof typeof SessionMetricsMeterName]; diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsMeterResponse.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsMeterResponse.ts new file mode 100644 index 000000000..abe3e9a66 --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsMeterResponse.ts @@ -0,0 +1,7 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../index.js"; + +export interface SessionMetricsMeterResponse { + meters: TrueForge.SessionMetricsMeter[]; +} diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsMeterUnit.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsMeterUnit.ts new file mode 100644 index 000000000..e6605f25c --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsMeterUnit.ts @@ -0,0 +1,8 @@ +// This file was auto-generated by Fern from our API Definition. + +export const SessionMetricsMeterUnit = { + Count: "count", + Usd: "$", + Ms: "ms", +} as const; +export type SessionMetricsMeterUnit = (typeof SessionMetricsMeterUnit)[keyof typeof SessionMetricsMeterUnit]; diff --git a/packages/trueforge-sdk/src/api/types/SessionMetricsPoint.ts b/packages/trueforge-sdk/src/api/types/SessionMetricsPoint.ts new file mode 100644 index 000000000..96011c20e --- /dev/null +++ b/packages/trueforge-sdk/src/api/types/SessionMetricsPoint.ts @@ -0,0 +1,6 @@ +// This file was auto-generated by Fern from our API Definition. + +export interface SessionMetricsPoint { + timestamp: string; + value: number; +} diff --git a/packages/trueforge-sdk/src/api/types/index.ts b/packages/trueforge-sdk/src/api/types/index.ts index 7baa28fe8..3f8ad4228 100644 --- a/packages/trueforge-sdk/src/api/types/index.ts +++ b/packages/trueforge-sdk/src/api/types/index.ts @@ -65,6 +65,9 @@ export * from "./GetModelProviderResponse.js"; export * from "./GetSandboxProviderCatalogResponse.js"; export * from "./GetSandboxProviderResponse.js"; export * from "./GetScheduleResponse.js"; +export * from "./GetSessionMetricsChartDataResponse.js"; +export * from "./GetSessionMetricsChartResponse.js"; +export * from "./GetSessionMetricsMeterResponse.js"; export * from "./GetSessionResponse.js"; export * from "./GetSkillCatalogResponse.js"; export * from "./GetSkillResponse.js"; @@ -151,6 +154,19 @@ export * from "./SessionAgentReference.js"; export * from "./SessionAgentSpecBody.js"; export * from "./SessionEvent.js"; export * from "./SessionEventItem.js"; +export * from "./SessionMetrics.js"; +export * from "./SessionMetricsChart.js"; +export * from "./SessionMetricsChartDataResponse.js"; +export * from "./SessionMetricsChartName.js"; +export * from "./SessionMetricsChartResponse.js"; +export * from "./SessionMetricsGraph.js"; +export * from "./SessionMetricsGraphLine.js"; +export * from "./SessionMetricsGraphUnit.js"; +export * from "./SessionMetricsMeter.js"; +export * from "./SessionMetricsMeterName.js"; +export * from "./SessionMetricsMeterResponse.js"; +export * from "./SessionMetricsMeterUnit.js"; +export * from "./SessionMetricsPoint.js"; export * from "./SettingsCapability.js"; export * from "./Skill.js"; export * from "./SkillCapability.js"; diff --git a/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsChartDataResponse.ts b/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsChartDataResponse.ts new file mode 100644 index 000000000..2bac15eed --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsChartDataResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsChartDataResponse } from "./SessionMetricsChartDataResponse.js"; + +export const GetSessionMetricsChartDataResponse: core.serialization.ObjectSchema< + serializers.GetSessionMetricsChartDataResponse.Raw, + TrueForge.GetSessionMetricsChartDataResponse +> = core.serialization.object({ + data: SessionMetricsChartDataResponse, +}); + +export declare namespace GetSessionMetricsChartDataResponse { + export interface Raw { + data: SessionMetricsChartDataResponse.Raw; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsChartResponse.ts b/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsChartResponse.ts new file mode 100644 index 000000000..e58f1d1fe --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsChartResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsChartResponse } from "./SessionMetricsChartResponse.js"; + +export const GetSessionMetricsChartResponse: core.serialization.ObjectSchema< + serializers.GetSessionMetricsChartResponse.Raw, + TrueForge.GetSessionMetricsChartResponse +> = core.serialization.object({ + data: SessionMetricsChartResponse, +}); + +export declare namespace GetSessionMetricsChartResponse { + export interface Raw { + data: SessionMetricsChartResponse.Raw; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsMeterResponse.ts b/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsMeterResponse.ts new file mode 100644 index 000000000..011db2559 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/GetSessionMetricsMeterResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsMeterResponse } from "./SessionMetricsMeterResponse.js"; + +export const GetSessionMetricsMeterResponse: core.serialization.ObjectSchema< + serializers.GetSessionMetricsMeterResponse.Raw, + TrueForge.GetSessionMetricsMeterResponse +> = core.serialization.object({ + data: SessionMetricsMeterResponse, +}); + +export declare namespace GetSessionMetricsMeterResponse { + export interface Raw { + data: SessionMetricsMeterResponse.Raw; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/Session.ts b/packages/trueforge-sdk/src/serialization/types/Session.ts index df9245838..cac861230 100644 --- a/packages/trueforge-sdk/src/serialization/types/Session.ts +++ b/packages/trueforge-sdk/src/serialization/types/Session.ts @@ -4,6 +4,7 @@ import type * as TrueForge from "../../api/index.js"; import * as core from "../../core/index.js"; import type * as serializers from "../index.js"; import { SessionAgent } from "./SessionAgent.js"; +import { SessionMetrics } from "./SessionMetrics.js"; export const Session: core.serialization.ObjectSchema = core.serialization.object({ @@ -11,6 +12,7 @@ export const Session: core.serialization.ObjectSchema = + core.serialization.object({ + totalCostInUsd: core.serialization.property("total_cost_in_usd", core.serialization.number()), + totalDurationMs: core.serialization.property("total_duration_ms", core.serialization.number()), + totalTurns: core.serialization.property("total_turns", core.serialization.number()), + }); + +export declare namespace SessionMetrics { + export interface Raw { + total_cost_in_usd: number; + total_duration_ms: number; + total_turns: number; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsChart.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChart.ts new file mode 100644 index 000000000..30cc92617 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChart.ts @@ -0,0 +1,25 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsChartName } from "./SessionMetricsChartName.js"; + +export const SessionMetricsChart: core.serialization.ObjectSchema< + serializers.SessionMetricsChart.Raw, + TrueForge.SessionMetricsChart +> = core.serialization.object({ + chartType: core.serialization.property("chart_type", core.serialization.stringLiteral("line")), + description: core.serialization.string(), + displayName: core.serialization.property("display_name", core.serialization.string()), + name: SessionMetricsChartName, +}); + +export declare namespace SessionMetricsChart { + export interface Raw { + chart_type: "line"; + description: string; + display_name: string; + name: SessionMetricsChartName.Raw; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartDataResponse.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartDataResponse.ts new file mode 100644 index 000000000..4bd201076 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartDataResponse.ts @@ -0,0 +1,21 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsGraph } from "./SessionMetricsGraph.js"; + +export const SessionMetricsChartDataResponse: core.serialization.ObjectSchema< + serializers.SessionMetricsChartDataResponse.Raw, + TrueForge.SessionMetricsChartDataResponse +> = core.serialization.object({ + graphs: core.serialization.list(SessionMetricsGraph), + step: core.serialization.string(), +}); + +export declare namespace SessionMetricsChartDataResponse { + export interface Raw { + graphs: SessionMetricsGraph.Raw[]; + step: string; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartName.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartName.ts new file mode 100644 index 000000000..f00810d3a --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartName.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const SessionMetricsChartName: core.serialization.Schema< + serializers.SessionMetricsChartName.Raw, + TrueForge.SessionMetricsChartName +> = core.serialization.enum_(["sessions_over_time", "sessions_cost_over_time", "turns_over_time"]); + +export declare namespace SessionMetricsChartName { + export type Raw = "sessions_over_time" | "sessions_cost_over_time" | "turns_over_time"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartResponse.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartResponse.ts new file mode 100644 index 000000000..f1883f768 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsChartResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsChart } from "./SessionMetricsChart.js"; + +export const SessionMetricsChartResponse: core.serialization.ObjectSchema< + serializers.SessionMetricsChartResponse.Raw, + TrueForge.SessionMetricsChartResponse +> = core.serialization.object({ + charts: core.serialization.list(SessionMetricsChart), +}); + +export declare namespace SessionMetricsChartResponse { + export interface Raw { + charts: SessionMetricsChart.Raw[]; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraph.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraph.ts new file mode 100644 index 000000000..4c3a5a002 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraph.ts @@ -0,0 +1,31 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsChartName } from "./SessionMetricsChartName.js"; +import { SessionMetricsGraphLine } from "./SessionMetricsGraphLine.js"; +import { SessionMetricsGraphUnit } from "./SessionMetricsGraphUnit.js"; + +export const SessionMetricsGraph: core.serialization.ObjectSchema< + serializers.SessionMetricsGraph.Raw, + TrueForge.SessionMetricsGraph +> = core.serialization.object({ + chartType: core.serialization.property("chart_type", core.serialization.stringLiteral("line")), + description: core.serialization.string(), + displayName: core.serialization.property("display_name", core.serialization.string()), + graphLines: core.serialization.property("graph_lines", core.serialization.list(SessionMetricsGraphLine)), + name: SessionMetricsChartName, + unit: SessionMetricsGraphUnit, +}); + +export declare namespace SessionMetricsGraph { + export interface Raw { + chart_type: "line"; + description: string; + display_name: string; + graph_lines: SessionMetricsGraphLine.Raw[]; + name: SessionMetricsChartName.Raw; + unit: SessionMetricsGraphUnit.Raw; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraphLine.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraphLine.ts new file mode 100644 index 000000000..0085ae321 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraphLine.ts @@ -0,0 +1,21 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsPoint } from "./SessionMetricsPoint.js"; + +export const SessionMetricsGraphLine: core.serialization.ObjectSchema< + serializers.SessionMetricsGraphLine.Raw, + TrueForge.SessionMetricsGraphLine +> = core.serialization.object({ + name: core.serialization.string(), + values: core.serialization.list(SessionMetricsPoint), +}); + +export declare namespace SessionMetricsGraphLine { + export interface Raw { + name: string; + values: SessionMetricsPoint.Raw[]; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraphUnit.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraphUnit.ts new file mode 100644 index 000000000..59dc40614 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsGraphUnit.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const SessionMetricsGraphUnit: core.serialization.Schema< + serializers.SessionMetricsGraphUnit.Raw, + TrueForge.SessionMetricsGraphUnit +> = core.serialization.enum_(["count", "$"]); + +export declare namespace SessionMetricsGraphUnit { + export type Raw = "count" | "$"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeter.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeter.ts new file mode 100644 index 000000000..3e1933c3c --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeter.ts @@ -0,0 +1,26 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsMeterName } from "./SessionMetricsMeterName.js"; +import { SessionMetricsMeterUnit } from "./SessionMetricsMeterUnit.js"; + +export const SessionMetricsMeter: core.serialization.ObjectSchema< + serializers.SessionMetricsMeter.Raw, + TrueForge.SessionMetricsMeter +> = core.serialization.object({ + aggregateValue: core.serialization.property("aggregate_value", core.serialization.number()), + description: core.serialization.string(), + name: SessionMetricsMeterName, + unit: SessionMetricsMeterUnit, +}); + +export declare namespace SessionMetricsMeter { + export interface Raw { + aggregate_value: number; + description: string; + name: SessionMetricsMeterName.Raw; + unit: SessionMetricsMeterUnit.Raw; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterName.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterName.ts new file mode 100644 index 000000000..d2c493397 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterName.ts @@ -0,0 +1,39 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const SessionMetricsMeterName: core.serialization.Schema< + serializers.SessionMetricsMeterName.Raw, + TrueForge.SessionMetricsMeterName +> = core.serialization.enum_([ + "total_sessions", + "total_cost_in_usd", + "total_turns", + "cost_per_session_in_usd", + "avg_turns_per_session", + "min_turns_per_session", + "max_turns_per_session", + "median_turns_per_session", + "min_session_duration_ms", + "max_session_duration_ms", + "median_session_duration_ms", + "p95_session_duration_ms", +]); + +export declare namespace SessionMetricsMeterName { + export type Raw = + | "total_sessions" + | "total_cost_in_usd" + | "total_turns" + | "cost_per_session_in_usd" + | "avg_turns_per_session" + | "min_turns_per_session" + | "max_turns_per_session" + | "median_turns_per_session" + | "min_session_duration_ms" + | "max_session_duration_ms" + | "median_session_duration_ms" + | "p95_session_duration_ms"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterResponse.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterResponse.ts new file mode 100644 index 000000000..c647611b4 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterResponse.ts @@ -0,0 +1,19 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; +import { SessionMetricsMeter } from "./SessionMetricsMeter.js"; + +export const SessionMetricsMeterResponse: core.serialization.ObjectSchema< + serializers.SessionMetricsMeterResponse.Raw, + TrueForge.SessionMetricsMeterResponse +> = core.serialization.object({ + meters: core.serialization.list(SessionMetricsMeter), +}); + +export declare namespace SessionMetricsMeterResponse { + export interface Raw { + meters: SessionMetricsMeter.Raw[]; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterUnit.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterUnit.ts new file mode 100644 index 000000000..f9128fd4a --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsMeterUnit.ts @@ -0,0 +1,14 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const SessionMetricsMeterUnit: core.serialization.Schema< + serializers.SessionMetricsMeterUnit.Raw, + TrueForge.SessionMetricsMeterUnit +> = core.serialization.enum_(["count", "$", "ms"]); + +export declare namespace SessionMetricsMeterUnit { + export type Raw = "count" | "$" | "ms"; +} diff --git a/packages/trueforge-sdk/src/serialization/types/SessionMetricsPoint.ts b/packages/trueforge-sdk/src/serialization/types/SessionMetricsPoint.ts new file mode 100644 index 000000000..9e838c6f5 --- /dev/null +++ b/packages/trueforge-sdk/src/serialization/types/SessionMetricsPoint.ts @@ -0,0 +1,20 @@ +// This file was auto-generated by Fern from our API Definition. + +import type * as TrueForge from "../../api/index.js"; +import * as core from "../../core/index.js"; +import type * as serializers from "../index.js"; + +export const SessionMetricsPoint: core.serialization.ObjectSchema< + serializers.SessionMetricsPoint.Raw, + TrueForge.SessionMetricsPoint +> = core.serialization.object({ + timestamp: core.serialization.string(), + value: core.serialization.number(), +}); + +export declare namespace SessionMetricsPoint { + export interface Raw { + timestamp: string; + value: number; + } +} diff --git a/packages/trueforge-sdk/src/serialization/types/index.ts b/packages/trueforge-sdk/src/serialization/types/index.ts index 7baa28fe8..3f8ad4228 100644 --- a/packages/trueforge-sdk/src/serialization/types/index.ts +++ b/packages/trueforge-sdk/src/serialization/types/index.ts @@ -65,6 +65,9 @@ export * from "./GetModelProviderResponse.js"; export * from "./GetSandboxProviderCatalogResponse.js"; export * from "./GetSandboxProviderResponse.js"; export * from "./GetScheduleResponse.js"; +export * from "./GetSessionMetricsChartDataResponse.js"; +export * from "./GetSessionMetricsChartResponse.js"; +export * from "./GetSessionMetricsMeterResponse.js"; export * from "./GetSessionResponse.js"; export * from "./GetSkillCatalogResponse.js"; export * from "./GetSkillResponse.js"; @@ -151,6 +154,19 @@ export * from "./SessionAgentReference.js"; export * from "./SessionAgentSpecBody.js"; export * from "./SessionEvent.js"; export * from "./SessionEventItem.js"; +export * from "./SessionMetrics.js"; +export * from "./SessionMetricsChart.js"; +export * from "./SessionMetricsChartDataResponse.js"; +export * from "./SessionMetricsChartName.js"; +export * from "./SessionMetricsChartResponse.js"; +export * from "./SessionMetricsGraph.js"; +export * from "./SessionMetricsGraphLine.js"; +export * from "./SessionMetricsGraphUnit.js"; +export * from "./SessionMetricsMeter.js"; +export * from "./SessionMetricsMeterName.js"; +export * from "./SessionMetricsMeterResponse.js"; +export * from "./SessionMetricsMeterUnit.js"; +export * from "./SessionMetricsPoint.js"; export * from "./SettingsCapability.js"; export * from "./Skill.js"; export * from "./SkillCapability.js"; diff --git a/packages/trueforge-sdk/tests/wire/internal/metrics.test.ts b/packages/trueforge-sdk/tests/wire/internal/metrics.test.ts new file mode 100644 index 000000000..4b36e3962 --- /dev/null +++ b/packages/trueforge-sdk/tests/wire/internal/metrics.test.ts @@ -0,0 +1,239 @@ +// This file was auto-generated by Fern from our API Definition. + +import * as TrueForgeTypes from "../../../src/api/index"; +import { TrueForge } from "../../../src/Client"; +import { mockServerPool } from "../../mock-server/MockServerPool"; + +describe("MetricsClient", () => { + test("list_charts", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { + data: { + charts: [ + { + chart_type: "line", + description: "description", + display_name: "display_name", + name: "sessions_over_time", + }, + ], + }, + }; + + server + .mockEndpoint() + .get("/internal/metrics/charts") + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.internal.metrics.listCharts(); + expect(response).toEqual({ + data: { + charts: [ + { + chartType: "line", + description: "description", + displayName: "display_name", + name: "sessions_over_time", + }, + ], + }, + }); + }); + + test("get_chart_data (1)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { + data: { + graphs: [ + { + chart_type: "line", + description: "description", + display_name: "display_name", + graph_lines: [{ name: "name", values: [{ timestamp: "timestamp", value: 1.1 }] }], + name: "sessions_over_time", + unit: "count", + }, + ], + step: "step", + }, + }; + + server + .mockEndpoint() + .get("/internal/metrics/charts-data") + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.internal.metrics.getChartData({ + agentId: "agent_id", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + chartName: "sessions_over_time", + }); + expect(response).toEqual({ + data: { + graphs: [ + { + chartType: "line", + description: "description", + displayName: "display_name", + graphLines: [ + { + name: "name", + values: [ + { + timestamp: "timestamp", + value: 1.1, + }, + ], + }, + ], + name: "sessions_over_time", + unit: "count", + }, + ], + step: "step", + }, + }); + }); + + test("get_chart_data (2)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/internal/metrics/charts-data") + .respondWith() + .statusCode(400) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.internal.metrics.getChartData({ + agentId: "x", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + chartName: "sessions_over_time", + }); + }).rejects.toThrow(TrueForgeTypes.BadRequestError); + }); + + test("get_chart_data (3)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/internal/metrics/charts-data") + .respondWith() + .statusCode(404) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.internal.metrics.getChartData({ + agentId: "x", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + chartName: "sessions_over_time", + }); + }).rejects.toThrow(TrueForgeTypes.NotFoundError); + }); + + test("get_meters (1)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { + data: { + meters: [{ aggregate_value: 1.1, description: "description", name: "total_sessions", unit: "count" }], + }, + }; + + server + .mockEndpoint() + .get("/internal/metrics/meters") + .respondWith() + .statusCode(200) + .jsonBody(rawResponseBody) + .build(); + + const response = await client.internal.metrics.getMeters({ + agentId: "agent_id", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + }); + expect(response).toEqual({ + data: { + meters: [ + { + aggregateValue: 1.1, + description: "description", + name: "total_sessions", + unit: "count", + }, + ], + }, + }); + }); + + test("get_meters (2)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/internal/metrics/meters") + .respondWith() + .statusCode(400) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.internal.metrics.getMeters({ + agentId: "x", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + }); + }).rejects.toThrow(TrueForgeTypes.BadRequestError); + }); + + test("get_meters (3)", async () => { + const server = mockServerPool.createServer(); + const client = new TrueForge({ maxRetries: 0, token: "test", baseUrl: server.baseUrl }); + + const rawResponseBody = { error: { message: "message" } }; + + server + .mockEndpoint() + .get("/internal/metrics/meters") + .respondWith() + .statusCode(404) + .jsonBody(rawResponseBody) + .build(); + + await expect(async () => { + return await client.internal.metrics.getMeters({ + agentId: "x", + startTimestamp: new Date("2024-01-15T09:30:00.000Z"), + endTimestamp: new Date("2024-01-15T09:30:00.000Z"), + }); + }).rejects.toThrow(TrueForgeTypes.NotFoundError); + }); +}); diff --git a/packages/trueforge-sdk/tests/wire/internal/sessions.test.ts b/packages/trueforge-sdk/tests/wire/internal/sessions.test.ts index 67b728e1f..65ec68a42 100644 --- a/packages/trueforge-sdk/tests/wire/internal/sessions.test.ts +++ b/packages/trueforge-sdk/tests/wire/internal/sessions.test.ts @@ -15,6 +15,7 @@ describe("SessionsClient", () => { created_at: "created_at", created_by: "created_by", id: "id", + metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, title: "title", updated_at: "updated_at", }, @@ -48,6 +49,11 @@ describe("SessionsClient", () => { createdAt: "created_at", createdBy: "created_by", id: "id", + metrics: { + totalCostInUsd: 1.1, + totalDurationMs: 1, + totalTurns: 1, + }, title: "title", updatedAt: "updated_at", }, diff --git a/packages/trueforge-sdk/tests/wire/sessions.test.ts b/packages/trueforge-sdk/tests/wire/sessions.test.ts index a39e4d8d7..3a3832433 100644 --- a/packages/trueforge-sdk/tests/wire/sessions.test.ts +++ b/packages/trueforge-sdk/tests/wire/sessions.test.ts @@ -16,6 +16,7 @@ describe("SessionsClient", () => { created_at: "created_at", created_by: "created_by", id: "id", + metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, title: "title", updated_at: "updated_at", }, @@ -45,6 +46,11 @@ describe("SessionsClient", () => { createdAt: "created_at", createdBy: "created_by", id: "id", + metrics: { + totalCostInUsd: 1.1, + totalDurationMs: 1, + totalTurns: 1, + }, title: "title", updatedAt: "updated_at", }, @@ -86,6 +92,7 @@ describe("SessionsClient", () => { created_at: "created_at", created_by: "created_by", id: "id", + metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, title: "title", updated_at: "updated_at", }, @@ -118,6 +125,11 @@ describe("SessionsClient", () => { createdAt: "created_at", createdBy: "created_by", id: "id", + metrics: { + totalCostInUsd: 1.1, + totalDurationMs: 1, + totalTurns: 1, + }, title: "title", updatedAt: "updated_at", }, @@ -206,6 +218,7 @@ describe("SessionsClient", () => { created_at: "created_at", created_by: "created_by", id: "id", + metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, title: "title", updated_at: "updated_at", }, @@ -233,6 +246,11 @@ describe("SessionsClient", () => { createdAt: "created_at", createdBy: "created_by", id: "id", + metrics: { + totalCostInUsd: 1.1, + totalDurationMs: 1, + totalTurns: 1, + }, title: "title", updatedAt: "updated_at", }, @@ -316,6 +334,7 @@ describe("SessionsClient", () => { created_at: "created_at", created_by: "created_by", id: "id", + metrics: { total_cost_in_usd: 1.1, total_duration_ms: 1, total_turns: 1 }, title: "title", updated_at: "updated_at", }, @@ -344,6 +363,11 @@ describe("SessionsClient", () => { createdAt: "created_at", createdBy: "created_by", id: "id", + metrics: { + totalCostInUsd: 1.1, + totalDurationMs: 1, + totalTurns: 1, + }, title: "title", updatedAt: "updated_at", }, diff --git a/packages/trueforge/scripts/write-openapi.ts b/packages/trueforge/scripts/write-openapi.ts index afb058658..bee0cedb2 100644 --- a/packages/trueforge/scripts/write-openapi.ts +++ b/packages/trueforge/scripts/write-openapi.ts @@ -24,6 +24,7 @@ import { SqliteMcpServerStore } from '../src/db/sqlite/mcp-server-store/SqliteMc import { SqliteModelProviderStore } from '../src/db/sqlite/model-provider-store/SqliteModelProviderStore'; import { SqliteSandboxProviderStore } from '../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; import { SqliteScheduleStore } from '../src/db/sqlite/schedule-store/SqliteScheduleStore'; +import { SqliteSessionMetricsStore } from '../src/db/sqlite/session-metrics/SqliteSessionMetricsStore'; import { SqliteSkillStore } from '../src/db/sqlite/skill-store/SqliteSkillStore'; import { SqliteOAuthTokenStore } from '../src/db/sqlite/token-store/SqliteOAuthTokenStore'; import { ActiveTurnRegistry } from '../src/runtime/activeTurns'; @@ -69,6 +70,7 @@ const app = createServerApp({ agentStore: new SqliteAgentStore(db), scheduleStore: new SqliteScheduleStore(db), sessionStore, + sessionMetricsStore: new SqliteSessionMetricsStore(db), sessions: new Sessions({ sessionStore }), activeTurns: new ActiveTurnRegistry(), requestReplyRouter: new RequestReplyRouter(), diff --git a/packages/trueforge/src/apis/sessionMetrics.ts b/packages/trueforge/src/apis/sessionMetrics.ts new file mode 100644 index 000000000..3e54af34e --- /dev/null +++ b/packages/trueforge/src/apis/sessionMetrics.ts @@ -0,0 +1,57 @@ +/** + * Internal session metrics APIs (mounted at /internal/metrics). + */ +import { OpenAPIHono, type RouteHandler } from '@hono/zod-openapi'; +import type { ResolveUserContext } from '../auth/identity'; +import { buildSessionMetricsCharts, type ISessionMetricsStore } from '../db/sessionMetricsStore'; +import { + getSessionMetricsChartsDataRoute, + getSessionMetricsChartsRoute, + getSessionMetricsMetersRoute, +} from '../routes/sessionMetricsRoutes'; +import { TENANT_ID } from './sessions'; + +export interface InternalMetricsRouterDeps { + sessionMetricsStore: ISessionMetricsStore; + resolveUserContext: ResolveUserContext; +} + +export function createInternalMetricsRouter(deps: InternalMetricsRouterDeps) { + const router = new OpenAPIHono(); + + const getSessionMetricsMetersHandler: RouteHandler = async c => { + const query = c.req.valid('query'); + const user = deps.resolveUserContext(c); + const metrics = await deps.sessionMetricsStore.getSessionMetricsMeters({ + tenant_id: TENANT_ID, + agent_id: query.agent_id, + created_by: user.userRef, + start_timestamp: query.start_timestamp, + end_timestamp: query.end_timestamp, + }); + return c.json({ data: metrics }, 200); + }; + + const getSessionMetricsChartsHandler: RouteHandler = c => { + return c.json({ data: buildSessionMetricsCharts() }, 200); + }; + + const getSessionMetricsChartsDataHandler: RouteHandler = async c => { + const query = c.req.valid('query'); + const user = deps.resolveUserContext(c); + const chartData = await deps.sessionMetricsStore.getSessionMetricsChartData({ + tenant_id: TENANT_ID, + agent_id: query.agent_id, + created_by: user.userRef, + start_timestamp: query.start_timestamp, + end_timestamp: query.end_timestamp, + chart_name: query.chart_name, + }); + return c.json({ data: chartData }, 200); + }; + + router.openapi(getSessionMetricsMetersRoute, getSessionMetricsMetersHandler); + router.openapi(getSessionMetricsChartsRoute, getSessionMetricsChartsHandler); + router.openapi(getSessionMetricsChartsDataRoute, getSessionMetricsChartsDataHandler); + return router; +} diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index dd5b76227..b6a44f9ce 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -65,6 +65,7 @@ export function toWireSession(record: SessionRecord): Session { created_by: record.created_by, created_at: record.created_at.toISOString(), updated_at: record.updated_at.toISOString(), + metrics: record.metrics, }; } diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index 3557888d1..28677b50c 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -18,6 +18,7 @@ import { createMcpOAuthRouter } from './apis/mcpOAuth'; import { createMcpServersRouter } from './apis/mcpServers'; import { createModelsRouter } from './apis/models'; import { createSchedulesRouter } from './apis/schedules'; +import { createInternalMetricsRouter } from './apis/sessionMetrics'; import { createInternalSessionsRouter, createSessionsRouter } from './apis/sessions'; import { createSettingsRouter } from './apis/settings'; import { createAvailableSkillsRouter } from './apis/skills'; @@ -34,6 +35,7 @@ import type { IMcpServerStore } from './db/mcpServerStore'; import type { IModelProviderStore } from './db/modelProviderStore'; import type { ISandboxProviderStore } from './db/sandboxProviderStore'; import type { IScheduleStore } from './db/scheduleStore'; +import type { ISessionMetricsStore } from './db/sessionMetricsStore'; import type { ISkillStore } from './db/skillStore'; import type { WithTransaction } from './db/transaction'; import type { IOAuthTokenStore } from './mcp/auth/types'; @@ -155,6 +157,7 @@ export interface ServerDeps { agentStore: IAgentStore; scheduleStore: IScheduleStore; sessionStore: ISessionStore; + sessionMetricsStore: ISessionMetricsStore; sessions: Sessions; activeTurns: ActiveTurnRegistry; /** Primary Redis client (server-owned); undefined in standalone mode. */ @@ -291,6 +294,15 @@ export function createServerApp(deps: ServerDeps) { }), ), ); + app.route( + '/internal/metrics', + withAuth( + createInternalMetricsRouter({ + sessionMetricsStore: deps.sessionMetricsStore, + resolveUserContext, + }), + ), + ); app.route( '/api/v1/sessions', withAuth( diff --git a/packages/trueforge/src/db/postgres/session-metrics/PostgresSessionMetricsStore.ts b/packages/trueforge/src/db/postgres/session-metrics/PostgresSessionMetricsStore.ts new file mode 100644 index 000000000..00968cfe2 --- /dev/null +++ b/packages/trueforge/src/db/postgres/session-metrics/PostgresSessionMetricsStore.ts @@ -0,0 +1,24 @@ +import type { Kysely } from 'kysely'; +import type { SessionMetricsChartDataResponse, SessionMetricsMeterResponse } from '../../../schemas/sessionMetrics'; +import type { + GetSessionMetricsChartDataInput, + GetSessionMetricsInput, + ISessionMetricsStore, +} from '../../sessionMetricsStore'; +import type { Database } from '../types'; +import { + getSessionMetricsChartData as getSessionMetricsChartDataQuery, + getSessionMetricsMeters as getSessionMetricsMetersQuery, +} from './queries'; + +export class PostgresSessionMetricsStore implements ISessionMetricsStore { + constructor(private readonly db: Kysely) {} + + getSessionMetricsMeters(input: GetSessionMetricsInput): Promise { + return getSessionMetricsMetersQuery(this.db, input); + } + + getSessionMetricsChartData(input: GetSessionMetricsChartDataInput): Promise { + return getSessionMetricsChartDataQuery(this.db, input); + } +} diff --git a/packages/trueforge/src/db/postgres/session-metrics/queries.ts b/packages/trueforge/src/db/postgres/session-metrics/queries.ts new file mode 100644 index 000000000..4a24b5afa --- /dev/null +++ b/packages/trueforge/src/db/postgres/session-metrics/queries.ts @@ -0,0 +1,116 @@ +import { sql, type Kysely } from 'kysely'; +import type { SessionMetricsChartDataResponse, SessionMetricsMeterResponse } from '../../../schemas/sessionMetrics'; +import { + buildSessionMetricsChartData, + buildSessionMetricsMeters, + sessionMetricsStepSeconds, + type GetSessionMetricsChartDataInput, + type GetSessionMetricsInput, + type SessionMetricsAggregate, + type SessionMetricsBucket, +} from '../../sessionMetricsStore'; +import type { Database } from '../types'; + +async function fetchSessionMetricsAggregate( + db: Kysely, + input: GetSessionMetricsInput, +): Promise { + // Every session in the window (including zero-turn / zero-duration); matches foldSessionMetricsAggregate. + // COALESCE keeps empty windows at 0 (percentile_cont would otherwise be null). + const aggregateRow = await db + .selectFrom('session') + .select([ + sql`COUNT(*)::int`.as('total_sessions'), + sql`COALESCE(SUM((metrics->>'total_turns')::bigint), 0)::double precision`.as('total_turns'), + sql`COALESCE(SUM((metrics->>'total_cost_in_usd')::double precision), 0)::double precision`.as( + 'total_cost_in_usd', + ), + sql`COALESCE(MIN((metrics->>'total_turns')::bigint), 0)::double precision`.as('min_turns_per_session'), + sql`COALESCE(MAX((metrics->>'total_turns')::bigint), 0)::double precision`.as('max_turns_per_session'), + sql`COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY (metrics->>'total_turns')::double precision), 0)::double precision`.as( + 'median_turns_per_session', + ), + sql`COALESCE(MIN((metrics->>'total_duration_ms')::bigint), 0)::double precision`.as( + 'min_session_duration_ms', + ), + sql`COALESCE(MAX((metrics->>'total_duration_ms')::bigint), 0)::double precision`.as( + 'max_session_duration_ms', + ), + sql`COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY (metrics->>'total_duration_ms')::double precision), 0)::double precision`.as( + 'median_session_duration_ms', + ), + sql`COALESCE(percentile_cont(0.95) WITHIN GROUP (ORDER BY (metrics->>'total_duration_ms')::double precision), 0)::double precision`.as( + 'p95_session_duration_ms', + ), + ]) + .where('tenant_id', '=', input.tenant_id) + .where('agent_id', '=', input.agent_id) + .where('created_by', '=', input.created_by) + .where('created_at', '>=', input.start_timestamp) + .where('created_at', '<=', input.end_timestamp) + .executeTakeFirstOrThrow(); + + return { + total_sessions: aggregateRow.total_sessions, + total_turns: aggregateRow.total_turns, + total_cost_in_usd: aggregateRow.total_cost_in_usd, + min_turns_per_session: aggregateRow.min_turns_per_session, + max_turns_per_session: aggregateRow.max_turns_per_session, + median_turns_per_session: aggregateRow.median_turns_per_session, + min_session_duration_ms: aggregateRow.min_session_duration_ms, + max_session_duration_ms: aggregateRow.max_session_duration_ms, + median_session_duration_ms: aggregateRow.median_session_duration_ms, + p95_session_duration_ms: aggregateRow.p95_session_duration_ms, + }; +} + +async function fetchSessionMetricsBuckets( + db: Kysely, + input: GetSessionMetricsInput, + step_seconds: number, +): Promise { + // Sparse buckets only; builders zero-fill missing intervals for the chart line. + const bucketTimestamp = sql` + (FLOOR(EXTRACT(EPOCH FROM created_at) / ${step_seconds}) * ${step_seconds})::double precision + `; + const bucketRows = await db + .selectFrom('session') + .select([ + bucketTimestamp.as('timestamp_seconds'), + sql`COUNT(*)::int`.as('sessions'), + sql`COALESCE(SUM((metrics->>'total_turns')::bigint), 0)::double precision`.as('turns'), + sql`COALESCE(SUM((metrics->>'total_cost_in_usd')::double precision), 0)::double precision`.as('cost'), + ]) + .where('tenant_id', '=', input.tenant_id) + .where('agent_id', '=', input.agent_id) + .where('created_by', '=', input.created_by) + .where('created_at', '>=', input.start_timestamp) + .where('created_at', '<=', input.end_timestamp) + .groupBy(sql`1`) + .orderBy('timestamp_seconds') + .execute(); + + return bucketRows.map(row => ({ + timestamp_seconds: row.timestamp_seconds, + sessions: row.sessions, + turns: row.turns, + cost: row.cost, + })); +} + +export async function getSessionMetricsMeters( + db: Kysely, + input: GetSessionMetricsInput, +): Promise { + const aggregate = await fetchSessionMetricsAggregate(db, input); + return buildSessionMetricsMeters(aggregate); +} + +export async function getSessionMetricsChartData( + db: Kysely, + input: GetSessionMetricsChartDataInput, +): Promise { + const step_seconds = sessionMetricsStepSeconds(input); + const buckets = await fetchSessionMetricsBuckets(db, input, step_seconds); + return buildSessionMetricsChartData({ query: input, buckets, step_seconds }); +} diff --git a/packages/trueforge/src/db/sessionMetricsStore.ts b/packages/trueforge/src/db/sessionMetricsStore.ts new file mode 100644 index 000000000..2ae4723e5 --- /dev/null +++ b/packages/trueforge/src/db/sessionMetricsStore.ts @@ -0,0 +1,346 @@ +import type { + SessionMetricsChartDataResponse, + SessionMetricsChartName, + SessionMetricsChartResponse, + SessionMetricsGraph, + SessionMetricsMeterResponse, + SessionMetricsPoint, +} from '../schemas/sessionMetrics'; + +export interface GetSessionMetricsInput { + tenant_id: string; + agent_id: string; + created_by: string; + start_timestamp: Date; + end_timestamp: Date; +} + +export interface GetSessionMetricsChartDataInput extends GetSessionMetricsInput { + chart_name: SessionMetricsChartName; +} + +export interface ISessionMetricsStore { + getSessionMetricsMeters(input: GetSessionMetricsInput): Promise; + getSessionMetricsChartData(input: GetSessionMetricsChartDataInput): Promise; +} + +/** Hour bucket size when the window is ≤ 24 hours. */ +export const SESSION_METRICS_HOUR_STEP_SECONDS = 60 * 60; +/** Day bucket size (86400; matches Monitor/DF daily chart step). */ +export const SESSION_METRICS_DAY_STEP_SECONDS = 24 * SESSION_METRICS_HOUR_STEP_SECONDS; + +/** Time window used for bucket step selection and zero-fill. */ +export interface SessionMetricsTimeWindow { + start_timestamp: Date; + end_timestamp: Date; +} + +/** Chart-data builder input; store `GetSessionMetricsChartDataInput` is structurally compatible. */ +export interface SessionMetricsChartDataBuildInput extends SessionMetricsTimeWindow { + chart_name: SessionMetricsChartName; +} + +export const SESSION_METRICS_CHARTS: SessionMetricsChartResponse['charts'] = [ + { + name: 'sessions_over_time', + display_name: 'Sessions', + description: 'Session starts over time', + chart_type: 'line', + }, + { + name: 'sessions_cost_over_time', + display_name: 'Cost', + description: 'Total session cost over time', + chart_type: 'line', + }, + { + name: 'turns_over_time', + display_name: 'Turns', + description: 'Total turns over time', + chart_type: 'line', + }, +]; + +export interface SessionMetricsAggregate { + total_sessions: number; + total_turns: number; + total_cost_in_usd: number; + min_turns_per_session: number; + max_turns_per_session: number; + median_turns_per_session: number; + min_session_duration_ms: number; + max_session_duration_ms: number; + median_session_duration_ms: number; + p95_session_duration_ms: number; +} + +/** Per-session counters used by {@link foldSessionMetricsAggregate}. */ +export interface SessionMetricsRow { + total_turns: number; + total_duration_ms: number; + total_cost_in_usd: number; +} + +export interface SessionMetricsBucket { + timestamp_seconds: number; + sessions: number; + turns: number; + cost: number; +} + +/** ≤24h → hourly buckets; longer windows → daily UTC. */ +export function sessionMetricsStepSeconds(input: SessionMetricsTimeWindow): number { + return input.end_timestamp.getTime() - input.start_timestamp.getTime() <= 24 * 60 * 60 * 1000 + ? SESSION_METRICS_HOUR_STEP_SECONDS + : SESSION_METRICS_DAY_STEP_SECONDS; +} + +function round({ value, digits }: { value: number; digits: number }): number { + const scale = 10 ** digits; + return Math.round(value * scale) / scale; +} + +/** Continuous (linear-interpolated) percentile over a sorted ascending array. */ +function continuousPercentile(sortedValues: number[], fraction: number): number { + if (sortedValues.length === 0) { + return 0; + } + const position = fraction * (sortedValues.length - 1); + const lowerIndex = Math.floor(position); + const upperIndex = Math.ceil(position); + const lower = sortedValues[lowerIndex] ?? 0; + const upper = sortedValues[upperIndex] ?? lower; + return lower + (upper - lower) * (position - lowerIndex); +} + +/** + * Fold matching sessions into meter inputs. Every session in the window is included + * (including zero-turn / zero-duration); SQLite / Postgres must match. + */ +export function foldSessionMetricsAggregate(rows: SessionMetricsRow[]): SessionMetricsAggregate { + const turnsPerSession: number[] = []; + const sessionDurations: number[] = []; + let total_turns = 0; + let total_cost_in_usd = 0; + for (const row of rows) { + total_turns += row.total_turns; + total_cost_in_usd += row.total_cost_in_usd; + turnsPerSession.push(row.total_turns); + sessionDurations.push(row.total_duration_ms); + } + turnsPerSession.sort((a, b) => a - b); + sessionDurations.sort((a, b) => a - b); + return { + total_sessions: rows.length, + total_turns, + total_cost_in_usd, + min_turns_per_session: turnsPerSession[0] ?? 0, + max_turns_per_session: turnsPerSession.at(-1) ?? 0, + median_turns_per_session: continuousPercentile(turnsPerSession, 0.5), + min_session_duration_ms: sessionDurations[0] ?? 0, + max_session_duration_ms: sessionDurations.at(-1) ?? 0, + median_session_duration_ms: continuousPercentile(sessionDurations, 0.5), + p95_session_duration_ms: continuousPercentile(sessionDurations, 0.95), + }; +} + +function buildMeters(aggregate: SessionMetricsAggregate): SessionMetricsMeterResponse['meters'] { + const costPerSession = + aggregate.total_sessions === 0 + ? 0 + : round({ value: aggregate.total_cost_in_usd / aggregate.total_sessions, digits: 3 }); + const avgTurnsPerSession = + aggregate.total_sessions === 0 ? 0 : round({ value: aggregate.total_turns / aggregate.total_sessions, digits: 2 }); + const medianTurns = round({ value: aggregate.median_turns_per_session, digits: 2 }); + const minDuration = Math.round(aggregate.min_session_duration_ms); + const maxDuration = Math.round(aggregate.max_session_duration_ms); + const medianDuration = Math.round(aggregate.median_session_duration_ms); + const p95Duration = Math.round(aggregate.p95_session_duration_ms); + + return [ + { + name: 'total_sessions', + aggregate_value: aggregate.total_sessions, + description: 'Total sessions', + unit: 'count', + }, + { + name: 'total_cost_in_usd', + aggregate_value: aggregate.total_cost_in_usd, + description: 'Total cost', + unit: '$', + }, + { + name: 'total_turns', + aggregate_value: aggregate.total_turns, + description: 'Total turns', + unit: 'count', + }, + { + name: 'cost_per_session_in_usd', + aggregate_value: costPerSession, + description: 'Total cost / total sessions', + unit: '$', + }, + { + name: 'avg_turns_per_session', + aggregate_value: avgTurnsPerSession, + description: 'Avg turns / session', + unit: 'count', + }, + { + name: 'min_turns_per_session', + aggregate_value: aggregate.min_turns_per_session, + description: 'Min turns', + unit: 'count', + }, + { + name: 'max_turns_per_session', + aggregate_value: aggregate.max_turns_per_session, + description: 'Max turns', + unit: 'count', + }, + { + name: 'median_turns_per_session', + aggregate_value: medianTurns, + description: 'Median turns', + unit: 'count', + }, + { + name: 'min_session_duration_ms', + aggregate_value: minDuration, + description: 'Min duration', + unit: 'ms', + }, + { + name: 'max_session_duration_ms', + aggregate_value: maxDuration, + description: 'Max duration', + unit: 'ms', + }, + { + name: 'median_session_duration_ms', + aggregate_value: medianDuration, + description: 'Median duration', + unit: 'ms', + }, + { + name: 'p95_session_duration_ms', + aggregate_value: p95Duration, + description: 'P95 duration', + unit: 'ms', + }, + ]; +} + +function bucketValue(bucket: SessionMetricsBucket | undefined, chart_name: SessionMetricsChartName): number { + if (bucket === undefined) { + return 0; + } + switch (chart_name) { + case 'sessions_over_time': + return bucket.sessions; + case 'sessions_cost_over_time': + return bucket.cost; + case 'turns_over_time': + return bucket.turns; + } +} + +function chartSeriesName(chart_name: SessionMetricsChartName): string { + switch (chart_name) { + case 'sessions_over_time': + return 'sessions'; + case 'sessions_cost_over_time': + return 'cost'; + case 'turns_over_time': + return 'turns'; + } +} + +function chartGraphMeta( + chart_name: SessionMetricsChartName, + step_seconds: number, +): Pick { + const hourly = step_seconds === SESSION_METRICS_HOUR_STEP_SECONDS; + switch (chart_name) { + case 'sessions_over_time': + return { + display_name: hourly ? 'Sessions per hour' : 'Sessions per day', + description: hourly ? 'How many sessions started each hour' : 'How many sessions started each day', + unit: 'count', + }; + case 'sessions_cost_over_time': + return { + display_name: hourly ? 'Cost per hour' : 'Cost per day', + description: hourly ? 'Total session cost each hour' : 'Total session cost each day', + unit: '$', + }; + case 'turns_over_time': + return { + display_name: hourly ? 'Turns per hour' : 'Turns per day', + description: hourly ? 'Total turns each hour' : 'Total turns each day', + unit: 'count', + }; + } +} + +function buildChartValues(input: { + query: SessionMetricsChartDataBuildInput; + buckets: SessionMetricsBucket[]; + step_seconds: number; +}): SessionMetricsPoint[] { + const byTimestamp = new Map(input.buckets.map(bucket => [bucket.timestamp_seconds, bucket])); + const values: SessionMetricsPoint[] = []; + const firstTimestampSeconds = + Math.floor(input.query.start_timestamp.getTime() / 1000 / input.step_seconds) * input.step_seconds; + + for ( + let timestampSeconds = firstTimestampSeconds; + // Inclusive end matches meters / SQL (`created_at <= end_timestamp`). + timestampSeconds * 1000 <= input.query.end_timestamp.getTime(); + timestampSeconds += input.step_seconds + ) { + const bucket = byTimestamp.get(timestampSeconds); + values.push({ + timestamp: new Date(timestampSeconds * 1000).toISOString(), + value: bucketValue(bucket, input.query.chart_name), + }); + } + + return values; +} + +export function buildSessionMetricsMeters(aggregate: SessionMetricsAggregate): SessionMetricsMeterResponse { + return { meters: buildMeters(aggregate) }; +} + +export function buildSessionMetricsCharts(): SessionMetricsChartResponse { + return { charts: SESSION_METRICS_CHARTS }; +} + +export function buildSessionMetricsChartData(input: { + query: SessionMetricsChartDataBuildInput; + buckets: SessionMetricsBucket[]; + step_seconds: number; +}): SessionMetricsChartDataResponse { + const meta = chartGraphMeta(input.query.chart_name, input.step_seconds); + return { + step: String(input.step_seconds), + graphs: [ + { + name: input.query.chart_name, + display_name: meta.display_name, + description: meta.description, + unit: meta.unit, + chart_type: 'line', + graph_lines: [ + { + name: chartSeriesName(input.query.chart_name), + values: buildChartValues(input), + }, + ], + }, + ], + }; +} diff --git a/packages/trueforge/src/db/sqlite/session-metrics/SqliteSessionMetricsStore.ts b/packages/trueforge/src/db/sqlite/session-metrics/SqliteSessionMetricsStore.ts new file mode 100644 index 000000000..a4a8d4cfb --- /dev/null +++ b/packages/trueforge/src/db/sqlite/session-metrics/SqliteSessionMetricsStore.ts @@ -0,0 +1,24 @@ +import type { Kysely } from 'kysely'; +import type { SessionMetricsChartDataResponse, SessionMetricsMeterResponse } from '../../../schemas/sessionMetrics'; +import type { + GetSessionMetricsChartDataInput, + GetSessionMetricsInput, + ISessionMetricsStore, +} from '../../sessionMetricsStore'; +import type { Database } from '../types'; +import { + getSessionMetricsChartData as getSessionMetricsChartDataQuery, + getSessionMetricsMeters as getSessionMetricsMetersQuery, +} from './queries'; + +export class SqliteSessionMetricsStore implements ISessionMetricsStore { + constructor(private readonly db: Kysely) {} + + getSessionMetricsMeters(input: GetSessionMetricsInput): Promise { + return getSessionMetricsMetersQuery(this.db, input); + } + + getSessionMetricsChartData(input: GetSessionMetricsChartDataInput): Promise { + return getSessionMetricsChartDataQuery(this.db, input); + } +} diff --git a/packages/trueforge/src/db/sqlite/session-metrics/queries.ts b/packages/trueforge/src/db/sqlite/session-metrics/queries.ts new file mode 100644 index 000000000..bedbed28d --- /dev/null +++ b/packages/trueforge/src/db/sqlite/session-metrics/queries.ts @@ -0,0 +1,88 @@ +import { sql, type Kysely } from 'kysely'; +import type { SessionMetricsChartDataResponse, SessionMetricsMeterResponse } from '../../../schemas/sessionMetrics'; +import { + buildSessionMetricsChartData, + buildSessionMetricsMeters, + foldSessionMetricsAggregate, + sessionMetricsStepSeconds, + type GetSessionMetricsChartDataInput, + type GetSessionMetricsInput, + type SessionMetricsAggregate, + type SessionMetricsBucket, +} from '../../sessionMetricsStore'; +import type { Database } from '../types'; + +async function fetchSessionMetricsAggregate( + db: Kysely, + input: GetSessionMetricsInput, +): Promise { + const start_timestamp = input.start_timestamp.toISOString(); + const end_timestamp = input.end_timestamp.toISOString(); + // Scan rows; fold via foldSessionMetricsAggregate (same as InMemory / Postgres). + const rows = await db + .selectFrom('session') + .select([ + sql`CAST(COALESCE(metrics->>'total_turns', 0) AS INTEGER)`.as('total_turns'), + sql`CAST(COALESCE(metrics->>'total_duration_ms', 0) AS INTEGER)`.as('total_duration_ms'), + sql`CAST(COALESCE(metrics->>'total_cost_in_usd', 0) AS REAL)`.as('total_cost_in_usd'), + ]) + .where('tenant_id', '=', input.tenant_id) + .where('agent_id', '=', input.agent_id) + .where('created_by', '=', input.created_by) + .where('created_at', '>=', start_timestamp) + .where('created_at', '<=', end_timestamp) + .execute(); + + return foldSessionMetricsAggregate(rows); +} + +async function fetchSessionMetricsBuckets( + db: Kysely, + input: GetSessionMetricsInput, + step_seconds: number, +): Promise { + const start_timestamp = input.start_timestamp.toISOString(); + const end_timestamp = input.end_timestamp.toISOString(); + // Sparse buckets only; builders zero-fill missing intervals for the chart line. + const bucketTimestamp = sql`CAST(unixepoch(created_at) / ${step_seconds} AS INTEGER) * ${step_seconds}`; + const buckets = await db + .selectFrom('session') + .select([ + bucketTimestamp.as('timestamp_seconds'), + sql`COUNT(*)`.as('sessions'), + sql`COALESCE(SUM(metrics->>'total_turns'), 0)`.as('turns'), + sql`COALESCE(SUM(metrics->>'total_cost_in_usd'), 0)`.as('cost'), + ]) + .where('tenant_id', '=', input.tenant_id) + .where('agent_id', '=', input.agent_id) + .where('created_by', '=', input.created_by) + .where('created_at', '>=', start_timestamp) + .where('created_at', '<=', end_timestamp) + .groupBy(sql`1`) + .orderBy('timestamp_seconds') + .execute(); + + return buckets.map(row => ({ + timestamp_seconds: row.timestamp_seconds, + sessions: row.sessions, + turns: row.turns, + cost: row.cost, + })); +} + +export async function getSessionMetricsMeters( + db: Kysely, + input: GetSessionMetricsInput, +): Promise { + const aggregate = await fetchSessionMetricsAggregate(db, input); + return buildSessionMetricsMeters(aggregate); +} + +export async function getSessionMetricsChartData( + db: Kysely, + input: GetSessionMetricsChartDataInput, +): Promise { + const step_seconds = sessionMetricsStepSeconds(input); + const buckets = await fetchSessionMetricsBuckets(db, input, step_seconds); + return buildSessionMetricsChartData({ query: input, buckets, step_seconds }); +} diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 5acd9f54a..398d043af 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -58,6 +58,7 @@ import type { IModelProviderStore } from './db/modelProviderStore'; import type { Database as PostgresDatabase } from './db/postgres/types'; import type { ISandboxProviderStore } from './db/sandboxProviderStore'; import type { IScheduleStore } from './db/scheduleStore'; +import type { ISessionMetricsStore } from './db/sessionMetricsStore'; import type { ISkillStore } from './db/skillStore'; import type { Database as SqliteDatabase } from './db/sqlite/types'; import type { WithTransaction } from './db/transaction'; @@ -72,6 +73,7 @@ import { printStandaloneStartupBanner } from './startupBanner'; /** Persistence + optional Redis wired for the selected topology. */ interface ServerPersistence { sessionStore: ISessionStore; + sessionMetricsStore: ISessionMetricsStore; modelProviderStore: IModelProviderStore; withTransaction: WithTransaction; mcpServerStore: IMcpServerStore; @@ -96,6 +98,7 @@ async function createStandalonePersistence(options: { import('./db/migrateSqlite'), Promise.all([ import('./db/sqlite/session-store/SqliteSessionStore'), + import('./db/sqlite/session-metrics/SqliteSessionMetricsStore'), import('./db/sqlite/model-provider-store/SqliteModelProviderStore'), import('./db/sqlite/mcp-server-store/SqliteMcpServerStore'), import('./db/sqlite/token-store/SqliteOAuthTokenStore'), @@ -107,6 +110,7 @@ async function createStandalonePersistence(options: { ]); const [ { SqliteSessionStore }, + { SqliteSessionMetricsStore }, { SqliteModelProviderStore }, { SqliteMcpServerStore }, { SqliteOAuthTokenStore }, @@ -123,6 +127,7 @@ async function createStandalonePersistence(options: { return { sessionStore: new SqliteSessionStore(db), + sessionMetricsStore: new SqliteSessionMetricsStore(db), modelProviderStore: new SqliteModelProviderStore(db), withTransaction: callback => db.transaction().execute(callback), mcpServerStore: new SqliteMcpServerStore(db), @@ -157,6 +162,7 @@ async function createDistributedPersistence(options: { import('./runtime/redis'), Promise.all([ import('./db/postgres/session-store/PostgresSessionStore'), + import('./db/postgres/session-metrics/PostgresSessionMetricsStore'), import('./db/postgres/model-provider-store/PostgresModelProviderStore'), import('./db/postgres/mcp-server-store/PostgresMcpServerStore'), import('./db/postgres/token-store/PostgresOAuthTokenStore'), @@ -168,6 +174,7 @@ async function createDistributedPersistence(options: { ]); const [ { PostgresSessionStore }, + { PostgresSessionMetricsStore }, { PostgresModelProviderStore }, { PostgresMcpServerStore }, { PostgresOAuthTokenStore }, @@ -189,6 +196,7 @@ async function createDistributedPersistence(options: { return { sessionStore: new PostgresSessionStore(db), + sessionMetricsStore: new PostgresSessionMetricsStore(db), modelProviderStore: new PostgresModelProviderStore(db), withTransaction: callback => db.transaction().execute(callback), mcpServerStore: new PostgresMcpServerStore(db), @@ -206,6 +214,7 @@ async function createDistributedPersistence(options: { async function createServerRuntime(persistence: ServerPersistence, logger: Logger) { const { sessionStore, + sessionMetricsStore, modelProviderStore, withTransaction, mcpServerStore, @@ -254,6 +263,7 @@ async function createServerRuntime(persistence: ServerPersistence< agentStore, scheduleStore, sessionStore, + sessionMetricsStore, sessions: new Sessions({ sessionStore }), activeTurns, redis, diff --git a/packages/trueforge/src/routes/sessionMetricsRoutes.ts b/packages/trueforge/src/routes/sessionMetricsRoutes.ts new file mode 100644 index 000000000..6b0647185 --- /dev/null +++ b/packages/trueforge/src/routes/sessionMetricsRoutes.ts @@ -0,0 +1,77 @@ +/** + * Internal session metrics route definitions (mounted at /internal/metrics). + * Handlers are registered in apis/sessionMetrics.ts. + */ +import { createRoute } from '@hono/zod-openapi'; +import { RequestErrorResponseSchema } from '../schemas/errors'; +import { + GetSessionMetricsChartDataRequestQuerySchema, + GetSessionMetricsChartDataResponseSchema, + GetSessionMetricsChartResponseSchema, + GetSessionMetricsMeterResponseSchema, + GetSessionMetricsRequestQuerySchema, +} from '../schemas/sessionMetrics'; +import { OpenApiTag } from './openapiTags'; + +export const getSessionMetricsMetersRoute = createRoute({ + method: 'get', + path: '/meters', + tags: [OpenApiTag.INTERNAL], + summary: 'Get session metrics meters', + description: "Aggregate the caller's session meters for a named agent over an inclusive creation-time window.", + 'x-fern-sdk-group-name': ['internal', 'metrics'], + 'x-fern-sdk-method-name': 'get_meters', + request: { + query: GetSessionMetricsRequestQuerySchema, + }, + responses: { + 200: { + content: { 'application/json': { schema: GetSessionMetricsMeterResponseSchema } }, + description: 'Session metric meters.', + }, + 400: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Invalid timestamps or a window longer than 30 days.', + }, + }, +}); + +export const getSessionMetricsChartsRoute = createRoute({ + method: 'get', + path: '/charts', + tags: [OpenApiTag.INTERNAL], + summary: 'Get session metrics charts', + description: 'List available session metric charts.', + 'x-fern-sdk-group-name': ['internal', 'metrics'], + 'x-fern-sdk-method-name': 'list_charts', + responses: { + 200: { + content: { 'application/json': { schema: GetSessionMetricsChartResponseSchema } }, + description: 'Available session metric charts.', + }, + }, +}); + +export const getSessionMetricsChartsDataRoute = createRoute({ + method: 'get', + path: '/charts-data', + tags: [OpenApiTag.INTERNAL], + summary: 'Get session metrics chart data', + description: + "Return one chart for the caller's sessions on a named agent over an inclusive creation-time window. Uses hourly buckets for windows up to 24 hours and daily UTC buckets otherwise.", + 'x-fern-sdk-group-name': ['internal', 'metrics'], + 'x-fern-sdk-method-name': 'get_chart_data', + request: { + query: GetSessionMetricsChartDataRequestQuerySchema, + }, + responses: { + 200: { + content: { 'application/json': { schema: GetSessionMetricsChartDataResponseSchema } }, + description: 'Zero-filled time series for one chart.', + }, + 400: { + content: { 'application/json': { schema: RequestErrorResponseSchema } }, + description: 'Invalid timestamps or a window longer than 30 days.', + }, + }, +}); diff --git a/packages/trueforge/src/schemas/sessionMetrics.ts b/packages/trueforge/src/schemas/sessionMetrics.ts new file mode 100644 index 000000000..af4e26f99 --- /dev/null +++ b/packages/trueforge/src/schemas/sessionMetrics.ts @@ -0,0 +1,183 @@ +/** + * Session metrics dashboard wire schemas (meters/charts). + * Per-session counters (`SessionMetrics`) stay in trueforge-core. + */ +import { z } from '@hono/zod-openapi'; + +/** Max inclusive created_at window for session metrics aggregation (30 days). */ +export const MAX_SESSION_METRICS_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +export const SessionMetricsPointSchema = z + .object({ + timestamp: z.string(), + value: z.number().nonnegative(), + }) + .strict() + .openapi('SessionMetricsPoint'); + +export const SessionMetricsMeterNameSchema = z.enum([ + 'total_sessions', + 'total_cost_in_usd', + 'total_turns', + 'cost_per_session_in_usd', + 'avg_turns_per_session', + 'min_turns_per_session', + 'max_turns_per_session', + 'median_turns_per_session', + 'min_session_duration_ms', + 'max_session_duration_ms', + 'median_session_duration_ms', + 'p95_session_duration_ms', +]); + +/** Wire value `$` needs a Fern-safe identifier for SDK codegen. */ +const METRICS_USD_FERN_ENUM = { + $: { name: 'USD' }, +} as const; + +export const MetricsUnitSchema = z.enum(['count', '$', 'ms']).openapi('MetricsUnit', { + 'x-fern-enum': METRICS_USD_FERN_ENUM, +}); + +export const SessionMetricsMeterSchema = z + .object({ + name: SessionMetricsMeterNameSchema, + aggregate_value: z.number().nonnegative(), + description: z.string(), + unit: MetricsUnitSchema, + }) + .strict() + .openapi('SessionMetricsMeter'); + +export const SessionMetricsMeterResponseSchema = z + .object({ + meters: z.array(SessionMetricsMeterSchema), + }) + .strict() + .openapi('SessionMetricsMeterResponse'); + +export const SessionMetricsChartNameSchema = z + .enum(['sessions_over_time', 'sessions_cost_over_time', 'turns_over_time']) + .describe('Session metrics chart to return.') + .openapi('SessionMetricsChartName'); + +export const SessionMetricsChartSchema = z + .object({ + name: SessionMetricsChartNameSchema, + display_name: z.string(), + description: z.string(), + chart_type: z.literal('line'), + }) + .strict() + .openapi('SessionMetricsChart'); + +export const SessionMetricsChartResponseSchema = z + .object({ + charts: z.array(SessionMetricsChartSchema), + }) + .strict() + .openapi('SessionMetricsChartResponse'); + +export const SessionMetricsGraphLineSchema = z + .object({ + name: z.string(), + values: z.array(SessionMetricsPointSchema), + }) + .strict() + .openapi('SessionMetricsGraphLine'); + +export const SessionMetricsGraphSchema = z + .object({ + name: SessionMetricsChartNameSchema, + display_name: z.string(), + description: z.string(), + unit: MetricsUnitSchema, + chart_type: z.literal('line'), + graph_lines: z.array(SessionMetricsGraphLineSchema), + }) + .strict() + .openapi('SessionMetricsGraph'); + +export const SessionMetricsChartDataResponseSchema = z + .object({ + step: z.string(), + graphs: z.array(SessionMetricsGraphSchema), + }) + .strict() + .openapi('SessionMetricsChartDataResponse'); + +/** Wire ISO-8601 (RFC 3339, offsets allowed) → Date for metrics window bounds. */ +const IsoTimestampQueryParam = z.iso + .datetime({ offset: true }) + .openapi({ type: 'string', format: 'date-time' }) + .transform(s => new Date(s)); + +const GetSessionMetricsRequestQueryObjectSchema = z.object({ + agent_id: z.string().min(1).max(64).describe('Named agent identifier.'), + start_timestamp: IsoTimestampQueryParam.describe('Inclusive lower bound on session `created_at`.'), + end_timestamp: IsoTimestampQueryParam.describe('Inclusive upper bound on session `created_at`.'), +}); + +function refineSessionMetricsTimeWindow( + query: { start_timestamp: Date; end_timestamp: Date }, + ctx: z.RefinementCtx, +): void { + const windowMs = query.end_timestamp.getTime() - query.start_timestamp.getTime(); + if (windowMs < 0) { + ctx.addIssue({ + code: 'custom', + message: 'end_timestamp must be on or after start_timestamp', + path: ['end_timestamp'], + }); + return; + } + if (windowMs > MAX_SESSION_METRICS_WINDOW_MS) { + ctx.addIssue({ + code: 'custom', + message: 'metrics window must not exceed 30 days', + path: ['end_timestamp'], + }); + } +} + +export const GetSessionMetricsRequestQuerySchema = GetSessionMetricsRequestQueryObjectSchema.superRefine( + refineSessionMetricsTimeWindow, +).openapi('GetSessionMetricsRequestQuery'); + +export const GetSessionMetricsChartDataRequestQuerySchema = z + .object({ + ...GetSessionMetricsRequestQueryObjectSchema.shape, + chart_name: SessionMetricsChartNameSchema, + }) + .superRefine(refineSessionMetricsTimeWindow) + .openapi('GetSessionMetricsChartDataRequestQuery'); + +export const GetSessionMetricsMeterResponseSchema = z + .object({ + data: SessionMetricsMeterResponseSchema, + }) + .openapi('GetSessionMetricsMeterResponse'); + +export const GetSessionMetricsChartResponseSchema = z + .object({ + data: SessionMetricsChartResponseSchema, + }) + .openapi('GetSessionMetricsChartResponse'); + +export const GetSessionMetricsChartDataResponseSchema = z + .object({ + data: SessionMetricsChartDataResponseSchema, + }) + .openapi('GetSessionMetricsChartDataResponse'); + +export type SessionMetricsPoint = z.infer; +export type SessionMetricsMeterName = z.infer; +export type MetricsUnit = z.infer; +export type SessionMetricsMeter = z.infer; +export type SessionMetricsMeterResponse = z.infer; +export type SessionMetricsChartName = z.infer; +export type SessionMetricsChart = z.infer; +export type SessionMetricsChartResponse = z.infer; +export type SessionMetricsGraphLine = z.infer; +export type SessionMetricsGraph = z.infer; +export type SessionMetricsChartDataResponse = z.infer; diff --git a/packages/trueforge/tests/db/postgres/session-metrics/contract.test.ts b/packages/trueforge/tests/db/postgres/session-metrics/contract.test.ts new file mode 100644 index 000000000..fc521189b --- /dev/null +++ b/packages/trueforge/tests/db/postgres/session-metrics/contract.test.ts @@ -0,0 +1,40 @@ +import { sql } from 'kysely'; + +import { PostgresSessionMetricsStore } from '../../../../src/db/postgres/session-metrics/PostgresSessionMetricsStore'; +import { PostgresSessionStore } from '../../../../src/db/postgres/session-store/PostgresSessionStore'; +import { runSessionMetricsStoreContractSuite } from '../../session-metrics/metricsContractSuite'; +import { createPostgresTestDatabase, type PostgresTestDatabase } from '../testDatabase'; + +const describePg = process.env['PG_STORE_TESTS_ENABLED'] === '1' ? describe : describe.skip; + +describePg('PostgresSessionMetricsStore (metrics contract)', () => { + let env: PostgresTestDatabase | undefined; + + beforeAll(async () => { + env = await createPostgresTestDatabase(); + if (env === undefined) { + throw new Error('Postgres test environment unavailable despite globalSetup probe'); + } + }, 120_000); + + afterAll(async () => { + await env?.teardown(); + }); + + beforeEach(async () => { + if (env !== undefined) { + await sql`TRUNCATE TABLE session CASCADE`.execute(env.db); + } + }); + + runSessionMetricsStoreContractSuite(() => { + if (env === undefined) { + throw new Error('Postgres test environment not initialized'); + } + const sessionStore = new PostgresSessionStore(env.db); + return { + sessionStore, + metricsStore: new PostgresSessionMetricsStore(env.db), + }; + }); +}); diff --git a/packages/trueforge/tests/db/session-metrics/metricsContractSuite.ts b/packages/trueforge/tests/db/session-metrics/metricsContractSuite.ts new file mode 100644 index 000000000..f2c56be94 --- /dev/null +++ b/packages/trueforge/tests/db/session-metrics/metricsContractSuite.ts @@ -0,0 +1,212 @@ +import type { ISessionStore } from '@truefoundry/trueforge-core/agent-session'; +import { + makeCreateTurnInput, + makeDoneTurnState, + makeTurnDoneEvent, +} from '../../../../trueforge-core/tests/agent-session/testHelpers'; +import type { ISessionMetricsStore } from '../../../src/db/sessionMetricsStore'; + +function mustGet(value: T | undefined | null, label = 'value'): T { + if (value === undefined || value === null) { + throw new Error(`Expected ${label} to be defined`); + } + return value; +} + +/** Shared meters/charts contract for Postgres / SQLite metrics stores. */ +export function runSessionMetricsStoreContractSuite( + createStores: () => { + sessionStore: ISessionStore; + metricsStore: ISessionMetricsStore; + }, +) { + const tenant = 't1'; + + describe('session metrics store', () => { + it('aggregates caller-owned named sessions and zero-fills hourly series', async () => { + const { sessionStore, metricsStore } = createStores(); + const start = new Date(Date.now() - 60 * 60 * 1000); + await sessionStore.createSession({ + tenant_id: tenant, + session_id: 'metrics-session', + created_by: 'user-1', + agent: { type: 'reference', id: 'agent-abc', name: 'Agent ABC' }, + custom: null, + external_id: null, + }); + await sessionStore.createSession({ + tenant_id: tenant, + session_id: 'other-user-metrics-session', + created_by: 'user-2', + agent: { type: 'reference', id: 'agent-abc', name: 'Agent ABC' }, + custom: null, + external_id: null, + }); + await sessionStore.createTurn(makeCreateTurnInput({ sessionId: 'metrics-session', turnId: 'metrics-turn' })); + const turn = mustGet(await sessionStore.getTurn({ session_id: 'metrics-session', turn_id: 'metrics-turn' })); + const state = { + ...makeDoneTurnState(), + completed_at: new Date(turn.created_at.getTime() + 1500).toISOString(), + metrics: { total_cost_in_usd: 1.25 }, + }; + await sessionStore.updateTurnState({ + session_id: 'metrics-session', + turn_id: 'metrics-turn', + state, + turn_done_event: makeTurnDoneEvent(state), + }); + + const metricsQuery = { + tenant_id: tenant, + agent_id: 'agent-abc', + created_by: 'user-1', + start_timestamp: start, + end_timestamp: new Date(start.getTime() + 2 * 60 * 60 * 1000), + }; + const meters = await metricsStore.getSessionMetricsMeters(metricsQuery); + const sessionsChart = await metricsStore.getSessionMetricsChartData({ + ...metricsQuery, + chart_name: 'sessions_over_time', + }); + const turnsChart = await metricsStore.getSessionMetricsChartData({ + ...metricsQuery, + chart_name: 'turns_over_time', + }); + const costChart = await metricsStore.getSessionMetricsChartData({ + ...metricsQuery, + chart_name: 'sessions_cost_over_time', + }); + + expect(sessionsChart.step).toBe('3600'); + expect(meters.meters).toHaveLength(12); + expect(meters.meters.find(meter => meter.name === 'total_sessions')?.aggregate_value).toBe(1); + expect(meters.meters.find(meter => meter.name === 'total_turns')?.aggregate_value).toBe(1); + expect(meters.meters.find(meter => meter.name === 'total_cost_in_usd')?.aggregate_value).toBe(1.25); + expect(meters.meters.find(meter => meter.name === 'avg_turns_per_session')?.aggregate_value).toBe(1); + expect(meters.meters.find(meter => meter.name === 'min_turns_per_session')?.aggregate_value).toBe(1); + expect(meters.meters.find(meter => meter.name === 'max_turns_per_session')?.aggregate_value).toBe(1); + expect(meters.meters.find(meter => meter.name === 'median_turns_per_session')?.aggregate_value).toBe(1); + expect(meters.meters.find(meter => meter.name === 'min_session_duration_ms')?.aggregate_value).toBe(1500); + expect(meters.meters.find(meter => meter.name === 'max_session_duration_ms')?.aggregate_value).toBe(1500); + expect(meters.meters.find(meter => meter.name === 'median_session_duration_ms')?.aggregate_value).toBe(1500); + expect(meters.meters.find(meter => meter.name === 'p95_session_duration_ms')?.aggregate_value).toBe(1500); + expect(sessionsChart.graphs[0]?.graph_lines[0]?.values.reduce((sum, point) => sum + point.value, 0)).toBe(1); + expect(turnsChart.graphs[0]?.graph_lines[0]?.values.reduce((sum, point) => sum + point.value, 0)).toBe(1); + expect(costChart.graphs[0]?.graph_lines[0]?.values.reduce((sum, point) => sum + point.value, 0)).toBe(1.25); + expect(sessionsChart.graphs[0]?.graph_lines[0]?.values.some(point => point.value === 0)).toBe(true); + + const dailyChart = await metricsStore.getSessionMetricsChartData({ + ...metricsQuery, + start_timestamp: new Date(start.getTime() - 24 * 60 * 60 * 1000), + end_timestamp: new Date(start.getTime() + 24 * 60 * 60 * 1000), + chart_name: 'sessions_over_time', + }); + expect(dailyChart.step).toBe('86400'); + }); + + it('calculates session distributions with continuous percentiles', async () => { + const { sessionStore, metricsStore } = createStores(); + const sessionDefinitions = [ + { id: 'metrics-inactive', turnDurations: [] as number[] }, + { id: 'metrics-one-turn', turnDurations: [100] }, + { id: 'metrics-two-turns', turnDurations: [200, 200] }, + { id: 'metrics-four-turns', turnDurations: [250, 250, 250, 250] }, + ]; + for (const definition of sessionDefinitions) { + await sessionStore.createSession({ + tenant_id: tenant, + session_id: definition.id, + created_by: 'user-1', + agent: { type: 'reference', id: 'agent-distributions', name: 'Agent Distributions' }, + custom: null, + external_id: null, + }); + for (const [index, durationMs] of definition.turnDurations.entries()) { + const turnId = `${definition.id}-turn-${String(index)}`; + await sessionStore.createTurn(makeCreateTurnInput({ sessionId: definition.id, turnId })); + const turn = mustGet(await sessionStore.getTurn({ session_id: definition.id, turn_id: turnId })); + const state = { + ...makeDoneTurnState(), + completed_at: new Date(turn.created_at.getTime() + durationMs).toISOString(), + }; + await sessionStore.updateTurnState({ + session_id: definition.id, + turn_id: turnId, + state, + turn_done_event: makeTurnDoneEvent(state), + }); + } + } + + const meters = await metricsStore.getSessionMetricsMeters({ + tenant_id: tenant, + agent_id: 'agent-distributions', + created_by: 'user-1', + start_timestamp: new Date(Date.now() - 60 * 60 * 1000), + end_timestamp: new Date(Date.now() + 60 * 60 * 1000), + }); + + // Includes zero-turn / zero-duration session: turns [0,1,2,4], durations [0,100,400,1000]. + expect(meters.meters.find(meter => meter.name === 'total_sessions')?.aggregate_value).toBe(4); + expect(meters.meters.find(meter => meter.name === 'total_turns')?.aggregate_value).toBe(7); + expect(meters.meters.find(meter => meter.name === 'avg_turns_per_session')?.aggregate_value).toBe(1.75); + expect(meters.meters.find(meter => meter.name === 'min_turns_per_session')?.aggregate_value).toBe(0); + expect(meters.meters.find(meter => meter.name === 'max_turns_per_session')?.aggregate_value).toBe(4); + expect(meters.meters.find(meter => meter.name === 'median_turns_per_session')?.aggregate_value).toBe(1.5); + expect(meters.meters.find(meter => meter.name === 'min_session_duration_ms')?.aggregate_value).toBe(0); + expect(meters.meters.find(meter => meter.name === 'max_session_duration_ms')?.aggregate_value).toBe(1000); + expect(meters.meters.find(meter => meter.name === 'median_session_duration_ms')?.aggregate_value).toBe(250); + expect(meters.meters.find(meter => meter.name === 'p95_session_duration_ms')?.aggregate_value).toBe(910); + }); + + it('includes zero-duration in-flight sessions in duration meters', async () => { + const { sessionStore, metricsStore } = createStores(); + const start = new Date(Date.now() - 60 * 60 * 1000); + await sessionStore.createSession({ + tenant_id: tenant, + session_id: 'inflight-session', + created_by: 'user-1', + agent: { type: 'reference', id: 'agent-inflight', name: 'Agent InFlight' }, + custom: null, + external_id: null, + }); + await sessionStore.createSession({ + tenant_id: tenant, + session_id: 'completed-session', + created_by: 'user-1', + agent: { type: 'reference', id: 'agent-inflight', name: 'Agent InFlight' }, + custom: null, + external_id: null, + }); + await sessionStore.createTurn(makeCreateTurnInput({ sessionId: 'inflight-session', turnId: 'inflight-turn' })); + await sessionStore.createTurn(makeCreateTurnInput({ sessionId: 'completed-session', turnId: 'completed-turn' })); + const turn = mustGet(await sessionStore.getTurn({ session_id: 'completed-session', turn_id: 'completed-turn' })); + const state = { + ...makeDoneTurnState(), + completed_at: new Date(turn.created_at.getTime() + 2000).toISOString(), + }; + await sessionStore.updateTurnState({ + session_id: 'completed-session', + turn_id: 'completed-turn', + state, + turn_done_event: makeTurnDoneEvent(state), + }); + + const meters = await metricsStore.getSessionMetricsMeters({ + tenant_id: tenant, + agent_id: 'agent-inflight', + created_by: 'user-1', + start_timestamp: start, + end_timestamp: new Date(start.getTime() + 2 * 60 * 60 * 1000), + }); + + expect(meters.meters.find(meter => meter.name === 'total_sessions')?.aggregate_value).toBe(2); + expect(meters.meters.find(meter => meter.name === 'total_turns')?.aggregate_value).toBe(2); + expect(meters.meters.find(meter => meter.name === 'min_turns_per_session')?.aggregate_value).toBe(1); + expect(meters.meters.find(meter => meter.name === 'min_session_duration_ms')?.aggregate_value).toBe(0); + expect(meters.meters.find(meter => meter.name === 'max_session_duration_ms')?.aggregate_value).toBe(2000); + expect(meters.meters.find(meter => meter.name === 'median_session_duration_ms')?.aggregate_value).toBe(1000); + expect(meters.meters.find(meter => meter.name === 'p95_session_duration_ms')?.aggregate_value).toBe(1900); + }); + }); +} diff --git a/packages/trueforge/tests/db/sqlite/session-metrics/contract.test.ts b/packages/trueforge/tests/db/sqlite/session-metrics/contract.test.ts new file mode 100644 index 000000000..d16eab4d9 --- /dev/null +++ b/packages/trueforge/tests/db/sqlite/session-metrics/contract.test.ts @@ -0,0 +1,24 @@ +import { SqliteSessionMetricsStore } from '../../../../src/db/sqlite/session-metrics/SqliteSessionMetricsStore'; +import { SqliteSessionStore } from '../../../../src/db/sqlite/session-store/SqliteSessionStore'; +import { runSessionMetricsStoreContractSuite } from '../../session-metrics/metricsContractSuite'; +import { createSqliteTestDatabase, type SqliteTestDatabase } from '../testDatabase'; + +describe('SqliteSessionMetricsStore (metrics contract)', () => { + let env: SqliteTestDatabase; + + beforeEach(async () => { + env = await createSqliteTestDatabase(); + }, 120_000); + + afterEach(async () => { + await env?.teardown(); + }); + + runSessionMetricsStoreContractSuite(() => { + const sessionStore = new SqliteSessionStore(env.db); + return { + sessionStore, + metricsStore: new SqliteSessionMetricsStore(env.db), + }; + }); +}); diff --git a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts index 4ceab3b02..5761a7f4f 100644 --- a/packages/trueforge/tests/unit/apis/sessionHttp.test.ts +++ b/packages/trueforge/tests/unit/apis/sessionHttp.test.ts @@ -3,6 +3,7 @@ import { AgentSpecSchema, Sessions } from '@truefoundry/trueforge-core/agent-ses import { RequestReplyRouter } from '@truefoundry/trueforge-core/request-reply'; import { createClient } from 'redis'; import { createLogger } from 'winston'; +import { createInternalMetricsRouter } from '../../../src/apis/sessionMetrics'; import { createInternalSessionsRouter, createSessionsRouter, @@ -16,9 +17,15 @@ import { createSqliteDb } from '../../../src/db/sqlite/client'; import { SqliteMcpServerStore } from '../../../src/db/sqlite/mcp-server-store/SqliteMcpServerStore'; import { SqliteModelProviderStore } from '../../../src/db/sqlite/model-provider-store/SqliteModelProviderStore'; import { SqliteSandboxProviderStore } from '../../../src/db/sqlite/sandbox-provider-store/SqliteSandboxProviderStore'; +import { SqliteSessionMetricsStore } from '../../../src/db/sqlite/session-metrics/SqliteSessionMetricsStore'; import { SqliteSessionStore } from '../../../src/db/sqlite/session-store/SqliteSessionStore'; import { SqliteSkillStore } from '../../../src/db/sqlite/skill-store/SqliteSkillStore'; import { ActiveTurnRegistry } from '../../../src/runtime/activeTurns'; +import { + GetSessionMetricsChartDataResponseSchema, + GetSessionMetricsChartResponseSchema, + GetSessionMetricsMeterResponseSchema, +} from '../../../src/schemas/sessionMetrics'; const inlineSpec = AgentSpecSchema.parse({ model: { name: 'anthropic/claude-sonnet-4-6' }, @@ -42,6 +49,7 @@ describe('sessions HTTP agent binding', () => { const db = createSqliteDb(':memory:'); await migrateSqliteToLatest(db); sessionStore = new SqliteSessionStore(db); + const sessionMetricsStore = new SqliteSessionMetricsStore(db); const sessions = new Sessions({ sessionStore }); const modelProviderStore = new SqliteModelProviderStore(db); const mcpServerStore = new SqliteMcpServerStore(db); @@ -83,6 +91,13 @@ describe('sessions HTTP agent binding', () => { }; app.route('/', createSessionsRouter(deps)); app.route('/internal/sessions', createInternalSessionsRouter(deps)); + app.route( + '/internal/metrics', + createInternalMetricsRouter({ + sessionMetricsStore, + resolveUserContext: deps.resolveUserContext, + }), + ); }); it('creates a session from an inline AgentSpec', async () => { @@ -93,11 +108,13 @@ describe('sessions HTTP agent binding', () => { id: string; created_by: string; agent: { type: 'inline'; spec: { instructions?: string } }; + metrics: unknown; }; }; expect(json.data.agent.type).toBe('inline'); expect(json.data.agent.spec.instructions).toBe('inline'); expect(json.data.created_by).toBe(LOCAL_USER_CONTEXT.userRef); + expect(json.data.metrics).toEqual({ total_cost_in_usd: 0, total_duration_ms: 0, total_turns: 0 }); }); it('returns 404 when creating a session for an unknown agent name', async () => { @@ -131,6 +148,80 @@ describe('sessions HTTP agent binding', () => { expect(listJson.data.some(row => row.id === json.data.id)).toBe(true); }); + it('returns caller-scoped metrics for a named agent', async () => { + const agent = await agentStore.createAgent({ + tenant_id: TENANT_ID, + name: 'metrics-agent', + manifest: inlineSpec, + }); + await sessionStore.createSession({ + tenant_id: TENANT_ID, + session_id: 'my-metrics-session', + created_by: LOCAL_USER_CONTEXT.userRef, + agent: { type: 'reference', id: agent.id, name: agent.name }, + custom: null, + external_id: null, + }); + await sessionStore.createSession({ + tenant_id: TENANT_ID, + session_id: 'other-user-metrics-session', + created_by: 'someone-else', + agent: { type: 'reference', id: agent.id, name: agent.name }, + custom: null, + external_id: null, + }); + const start = new Date(Date.now() - 60 * 60 * 1000); + const end = new Date(Date.now() + 60 * 60 * 1000); + const query = new URLSearchParams({ + agent_id: agent.id, + start_timestamp: start.toISOString(), + end_timestamp: end.toISOString(), + }); + + const response = await app.request(`/internal/metrics/meters?${query.toString()}`); + + expect(response.status).toBe(200); + const meters = GetSessionMetricsMeterResponseSchema.parse(await response.json()); + expect(meters.data.meters).toHaveLength(12); + expect(meters.data.meters.find(meter => meter.name === 'total_sessions')?.aggregate_value).toBe(1); + expect(meters.data.meters.find(meter => meter.name === 'total_turns')?.aggregate_value).toBe(0); + expect(meters.data.meters.find(meter => meter.name === 'total_cost_in_usd')?.aggregate_value).toBe(0); + expect(meters.data.meters.find(meter => meter.name === 'avg_turns_per_session')?.aggregate_value).toBe(0); + expect(meters.data.meters.find(meter => meter.name === 'p95_session_duration_ms')?.aggregate_value).toBe(0); + + const sessionsChartResponse = await app.request( + `/internal/metrics/charts-data?${query.toString()}&chart_name=sessions_over_time`, + ); + expect(sessionsChartResponse.status).toBe(200); + const sessionsChart = GetSessionMetricsChartDataResponseSchema.parse(await sessionsChartResponse.json()); + expect(sessionsChart.data.graphs[0]?.graph_lines[0]?.values.reduce((sum, point) => sum + point.value, 0)).toBe(1); + }); + + it('returns the static session metrics charts', async () => { + const response = await app.request('/internal/metrics/charts'); + + expect(response.status).toBe(200); + const payload = GetSessionMetricsChartResponseSchema.parse(await response.json()); + expect(payload.data.charts).toHaveLength(3); + expect(payload.data.charts.map(chart => chart.name)).toEqual([ + 'sessions_over_time', + 'sessions_cost_over_time', + 'turns_over_time', + ]); + }); + + it('rejects session metrics windows longer than 30 days', async () => { + const query = new URLSearchParams({ + agent_id: 'agent-1', + start_timestamp: '2026-01-01T00:00:00.000Z', + end_timestamp: '2026-02-01T00:00:00.000Z', + }); + + const response = await app.request(`/internal/metrics/meters?${query.toString()}`); + + expect(response.status).toBe(400); + }); + it("rejects access to another user's session on get/update/delete/cancel/events and scopes list", async () => { await sessionStore.createSession({ tenant_id: TENANT_ID, diff --git a/packages/trueforge/tests/unit/session-metrics/sessionMetrics.test.ts b/packages/trueforge/tests/unit/session-metrics/sessionMetrics.test.ts new file mode 100644 index 000000000..a3ce36214 --- /dev/null +++ b/packages/trueforge/tests/unit/session-metrics/sessionMetrics.test.ts @@ -0,0 +1,132 @@ +import { + buildSessionMetricsChartData, + buildSessionMetricsCharts, + buildSessionMetricsMeters, + foldSessionMetricsAggregate, + sessionMetricsStepSeconds, +} from '../../../src/db/sessionMetricsStore'; + +const emptyAggregate = { + total_sessions: 0, + total_turns: 0, + total_cost_in_usd: 0, + min_turns_per_session: 0, + max_turns_per_session: 0, + median_turns_per_session: 0, + min_session_duration_ms: 0, + max_session_duration_ms: 0, + median_session_duration_ms: 0, + p95_session_duration_ms: 0, +}; + +describe('session metrics builders', () => { + it('emits inclusive end bucket for an exact 24h hour-aligned window', () => { + const query = { + tenant_id: 'default', + agent_id: 'agent-1', + created_by: 'user-1', + start_timestamp: new Date('2026-08-27T00:00:00.000Z'), + end_timestamp: new Date('2026-08-28T00:00:00.000Z'), + }; + const step_seconds = sessionMetricsStepSeconds(query); + const endBucketSeconds = Math.floor(query.end_timestamp.getTime() / 1000 / step_seconds) * step_seconds; + const chartData = buildSessionMetricsChartData({ + query: { ...query, chart_name: 'sessions_over_time' }, + buckets: [{ timestamp_seconds: endBucketSeconds, sessions: 1, turns: 0, cost: 0 }], + step_seconds, + }); + + expect(step_seconds).toBe(3600); + expect(chartData.graphs[0]?.graph_lines[0]?.values).toHaveLength(25); + expect(chartData.graphs[0]?.graph_lines[0]?.values[0]?.timestamp).toBe('2026-08-27T00:00:00.000Z'); + expect(chartData.graphs[0]?.graph_lines[0]?.values[24]?.timestamp).toBe('2026-08-28T00:00:00.000Z'); + expect(chartData.graphs[0]?.graph_lines[0]?.values[24]?.value).toBe(1); + }); + + it('builds the static chart catalog', () => { + const charts = buildSessionMetricsCharts(); + expect(charts.charts).toHaveLength(3); + expect(charts.charts.map(chart => chart.name)).toEqual([ + 'sessions_over_time', + 'sessions_cost_over_time', + 'turns_over_time', + ]); + }); + + it('builds the complete meter list with derived averages', () => { + const metrics = buildSessionMetricsMeters({ + total_sessions: 4, + total_turns: 7, + total_cost_in_usd: 1, + min_turns_per_session: 0, + max_turns_per_session: 4, + median_turns_per_session: 1.5, + min_session_duration_ms: 0, + max_session_duration_ms: 1000, + median_session_duration_ms: 250, + p95_session_duration_ms: 910, + }); + + expect(metrics.meters).toHaveLength(12); + expect(metrics.meters.find(meter => meter.name === 'cost_per_session_in_usd')).toEqual({ + name: 'cost_per_session_in_usd', + aggregate_value: 0.25, + description: 'Total cost / total sessions', + unit: '$', + }); + expect(metrics.meters.find(meter => meter.name === 'avg_turns_per_session')?.aggregate_value).toBe(1.75); + expect(metrics.meters.find(meter => meter.name === 'p95_session_duration_ms')?.aggregate_value).toBe(910); + }); + + it('maps bucket fields by chart name', () => { + const query = { + tenant_id: 'default', + agent_id: 'agent-1', + created_by: 'user-1', + start_timestamp: new Date('2026-08-27T00:00:00.000Z'), + end_timestamp: new Date('2026-08-27T01:00:00.000Z'), + }; + const startSeconds = Math.floor(query.start_timestamp.getTime() / 1000); + const buckets = [{ timestamp_seconds: startSeconds, sessions: 2, turns: 3, cost: 1.25 }]; + + expect( + buildSessionMetricsChartData({ + query: { ...query, chart_name: 'sessions_over_time' }, + buckets, + step_seconds: 3600, + }).graphs[0]?.graph_lines[0]?.values[0]?.value, + ).toBe(2); + expect( + buildSessionMetricsChartData({ + query: { ...query, chart_name: 'turns_over_time' }, + buckets, + step_seconds: 3600, + }).graphs[0]?.graph_lines[0]?.values[0]?.value, + ).toBe(3); + expect( + buildSessionMetricsChartData({ + query: { ...query, chart_name: 'sessions_cost_over_time' }, + buckets, + step_seconds: 3600, + }).graphs[0]?.graph_lines[0]?.values[0]?.value, + ).toBe(1.25); + }); + + it('returns empty meters for an empty aggregate', () => { + expect(buildSessionMetricsMeters(emptyAggregate).meters).toHaveLength(12); + }); + + it('includes zero-turn and zero-duration sessions in distributions', () => { + const aggregate = foldSessionMetricsAggregate([ + { total_turns: 0, total_duration_ms: 0, total_cost_in_usd: 0 }, + { total_turns: 1, total_duration_ms: 0, total_cost_in_usd: 0 }, + { total_turns: 1, total_duration_ms: 2000, total_cost_in_usd: 0.5 }, + ]); + expect(aggregate.total_sessions).toBe(3); + expect(aggregate.min_turns_per_session).toBe(0); + expect(aggregate.median_turns_per_session).toBe(1); + expect(aggregate.min_session_duration_ms).toBe(0); + expect(aggregate.median_session_duration_ms).toBe(0); + expect(aggregate.p95_session_duration_ms).toBeCloseTo(1800); + }); +});