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);
+ });
+});