From 5e375420a859e7a41b2116981e10e8ef5506a2f2 Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 17:27:04 +0800
Subject: [PATCH 01/40] fix: add AutoTransition to loading/idle state
transitions on management pages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Wrap buttons and content areas with AutoTransition for smooth fade
animations between loading and idle states on API Keys, Scheduled Tasks,
and System Performance pages — matching the existing pattern in team/user
settings.
Co-Authored-By: Claude Opus 4.7
---
src/components/dashboard/api-keys-client.tsx | 335 +++++++++++-------
.../dashboard/scheduled-tasks-client.tsx | 57 +--
.../dashboard/system-performance-client.tsx | 179 +++++++---
3 files changed, 370 insertions(+), 201 deletions(-)
diff --git a/src/components/dashboard/api-keys-client.tsx b/src/components/dashboard/api-keys-client.tsx
index b5ec5cbc..f38d53cd 100644
--- a/src/components/dashboard/api-keys-client.tsx
+++ b/src/components/dashboard/api-keys-client.tsx
@@ -20,6 +20,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
+import { AutoTransition } from "@/components/ui/auto-transition";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -41,6 +42,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
import {
Table,
TableBody,
@@ -326,143 +328,194 @@ export function ApiKeysClient({
- {loading ? (
-
- {copy.loading}
-
- ) : keys.length === 0 ? (
-
- ) : (
-
-
-
- {copy.columns.name}
- {copy.columns.scopes}
- {copy.columns.sites}
- {copy.columns.expires}
- {copy.columns.lastUsed}
- {copy.columns.status}
-
- {copy.columns.action}
-
-
-
-
- {keys.map((key) => (
-
-
- {key.name}
-
- {key.prefix}
-
-
-
-
- {key.scopes.map((scope) => (
-
- {scopeLabel(copy, scope)}
-
- ))}
-
-
-
- {siteScopeLabel(key)}
-
-
- {key.expiresAt
- ? dateTime(locale, key.expiresAt)
- : copy.neverExpires}
-
-
- {key.lastUsedAt
- ? dateTime(locale, key.lastUsedAt)
- : copy.notUsed}
-
-
-
- {copy.status[key.status]}
-
-
-
-
-
-
-
-
-
-
- {copy.rotate}
-
- {copy.rotateConfirm}
-
-
-
-
- {cancelLabel}
-
- void rotateKey(key.id)}
+
+ {loading ? (
+
+ {copy.loading}
+
+ ) : keys.length === 0 ? (
+
+ ) : (
+
+
+
+ {copy.columns.name}
+ {copy.columns.scopes}
+ {copy.columns.sites}
+ {copy.columns.expires}
+ {copy.columns.lastUsed}
+ {copy.columns.status}
+
+ {copy.columns.action}
+
+
+
+
+ {keys.map((key) => (
+
+
+ {key.name}
+
+ {key.prefix}
+
+
+
+
+ {key.scopes.map((scope) => (
+
+ {scopeLabel(copy, scope)}
+
+ ))}
+
+
+
+ {siteScopeLabel(key)}
+
+
+ {key.expiresAt
+ ? dateTime(locale, key.expiresAt)
+ : copy.neverExpires}
+
+
+ {key.lastUsedAt
+ ? dateTime(locale, key.lastUsedAt)
+ : copy.notUsed}
+
+
+
+ {copy.status[key.status]}
+
+
+
+
+
+
+
-
-
-
-
-
-
- {copy.revoke}
-
- {copy.revokeConfirm}
-
-
-
-
- {cancelLabel}
-
-
+ {busyKeyId === key.id ? (
+
+
+ {copy.rotate}
+
+ ) : (
+
+
+ {copy.rotate}
+
+ )}
+
+
+
+
+
+
+ {copy.rotate}
+
+
+ {copy.rotateConfirm}
+
+
+
+
+ {cancelLabel}
+
+ void rotateKey(key.id)}
+ >
+ {copy.rotate}
+
+
+
+
+
+
+
-
-
-
- ))}
-
-
- )}
+
+ {busyKeyId === key.id ? (
+
+
+ {copy.revoke}
+
+ ) : (
+ {copy.revoke}
+ )}
+
+
+
+
+
+
+ {copy.revoke}
+
+
+ {copy.revokeConfirm}
+
+
+
+
+ {cancelLabel}
+
+ void revokeKey(key.id)}
+ >
+ {copy.revoke}
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
@@ -565,7 +618,19 @@ export function ApiKeysClient({
diff --git a/src/components/dashboard/scheduled-tasks-client.tsx b/src/components/dashboard/scheduled-tasks-client.tsx
index 1b22bb1e..129da073 100644
--- a/src/components/dashboard/scheduled-tasks-client.tsx
+++ b/src/components/dashboard/scheduled-tasks-client.tsx
@@ -192,16 +192,25 @@ function HealthCell({
{label}
-
{detail}
@@ -679,14 +697,23 @@ function DoDiagnosticKv({
return (
From f099c6ab2a4b91f2a5e04af9b714c78358bd036a Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 17:29:22 +0800
Subject: [PATCH 02/40] fix: generalize cross-breakdown dimensions and align
OpenAPI spec
- Extend cross-breakdown endpoint to support 27 analytics dimensions
(page, referrer, UTM, client, geo) instead of only 6 client dimensions
- Add resolveCrossBreakdownDimension() for generic dimension-to-SQL mapping
- Remove timeRange/filters from FunnelAnalysisRequest OpenAPI schema
to match actual Zod validation (timeRange via query params only)
- Add missing fieldValueType parameter to event-fields/values OpenAPI spec
---
docs/openapi.json | 38 +++----
docs/openapi.yaml | 37 ++++---
scripts/generate-openapi.ts | 22 ++--
src/lib/edge/__tests__/api-v1.test.ts | 8 ++
.../query-technology-coverage.test.ts | 33 +++---
.../query-technology-handlers.test.ts | 60 +++++-----
src/lib/edge/__tests__/query.test.ts | 4 +-
src/lib/edge/api-v1-helpers.ts | 22 ++++
src/lib/edge/api-v1.ts | 7 +-
src/lib/edge/query/core-dimensions.ts | 103 ++++++++++++++++++
src/lib/edge/query/router.ts | 4 +-
src/lib/edge/query/technology/client-cross.ts | 23 ++--
src/lib/edge/query/technology/handlers.ts | 23 ++--
13 files changed, 264 insertions(+), 120 deletions(-)
diff --git a/docs/openapi.json b/docs/openapi.json
index fb039558..fe5a8059 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2334,7 +2334,7 @@
"get": {
"operationId": "getAnalyticsCrossBreakdown",
"summary": "Get analytics cross breakdown",
- "description": "Returns a two-dimensional analytics breakdown.",
+ "description": "Returns a two-dimensional analytics breakdown. Supports page, referrer, UTM, client, and geo dimensions. Session and event dimensions are not supported.",
"tags": ["Analytics"],
"parameters": [
{
@@ -2390,7 +2390,7 @@
"type": "string",
"maxLength": 120
},
- "description": "Primary dimension."
+ "description": "Primary dimension (e.g. client.browser, geo.country, page.path)."
},
{
"name": "secondary",
@@ -2399,7 +2399,7 @@
"type": "string",
"maxLength": 120
},
- "description": "Secondary dimension."
+ "description": "Secondary dimension (must differ from primary)."
},
{
"name": "metric",
@@ -3693,6 +3693,15 @@
},
"description": "Field path."
},
+ {
+ "name": "fieldValueType",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "enum": ["string", "number", "boolean", "null", "object", "array"]
+ },
+ "description": "Expected value type for the field."
+ },
{
"name": "search",
"in": "query",
@@ -4810,11 +4819,6 @@
"default": {
"summary": "Analyze an ad-hoc funnel",
"value": {
- "timeRange": {
- "from": "2026-05-27T00:00:00Z",
- "to": "2026-06-26T00:00:00Z",
- "timeZone": "Asia/Shanghai"
- },
"steps": [
{
"type": "pageview",
@@ -4826,13 +4830,6 @@
"value": "signup",
"label": "Signup"
}
- ],
- "filters": [
- {
- "field": "geo.country",
- "op": "in",
- "value": ["US", "CA"]
- }
]
}
}
@@ -8080,12 +8077,9 @@
},
"FunnelAnalysisRequest": {
"type": "object",
- "description": "Request for ad-hoc funnel analysis.",
+ "description": "Request for ad-hoc funnel analysis. Use query parameters (from, to, preset, timeZone) for time range.",
"required": ["steps"],
"properties": {
- "timeRange": {
- "$ref": "#/components/schemas/TimeRangeInput"
- },
"steps": {
"type": "array",
"minItems": 2,
@@ -8093,12 +8087,6 @@
"items": {
"$ref": "#/components/schemas/FunnelStepInput"
}
- },
- "filters": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/ComplexFilter"
- }
}
},
"additionalProperties": false
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index c2da3cf7..5d8e356e 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1637,7 +1637,9 @@ paths:
get:
operationId: getAnalyticsCrossBreakdown
summary: Get analytics cross breakdown
- description: Returns a two-dimensional analytics breakdown.
+ description: Returns a two-dimensional analytics breakdown. Supports page,
+ referrer, UTM, client, and geo dimensions. Session and event dimensions
+ are not supported.
tags:
- Analytics
parameters:
@@ -1686,13 +1688,13 @@ paths:
schema:
type: string
maxLength: 120
- description: Primary dimension.
+ description: Primary dimension (e.g. client.browser, geo.country, page.path).
- name: secondary
in: query
schema:
type: string
maxLength: 120
- description: Secondary dimension.
+ description: Secondary dimension (must differ from primary).
- name: metric
in: query
schema:
@@ -2613,6 +2615,18 @@ paths:
type: string
maxLength: 240
description: Field path.
+ - name: fieldValueType
+ in: query
+ schema:
+ type: string
+ enum:
+ - string
+ - number
+ - boolean
+ - "null"
+ - object
+ - array
+ description: Expected value type for the field.
- name: search
in: query
schema:
@@ -3351,14 +3365,7 @@ paths:
default:
summary: Analyze an ad-hoc funnel
value:
- timeRange: *a1
steps: *a11
- filters:
- - field: geo.country
- op: in
- value:
- - US
- - CA
responses:
"200":
description: Successful response
@@ -5568,22 +5575,18 @@ components:
additionalProperties: false
FunnelAnalysisRequest:
type: object
- description: Request for ad-hoc funnel analysis.
+ description:
+ Request for ad-hoc funnel analysis. Use query parameters (from, to,
+ preset, timeZone) for time range.
required:
- steps
properties:
- timeRange:
- $ref: "#/components/schemas/TimeRangeInput"
steps:
type: array
minItems: 2
maxItems: 10
items:
$ref: "#/components/schemas/FunnelStepInput"
- filters:
- type: array
- items:
- $ref: "#/components/schemas/ComplexFilter"
additionalProperties: false
FunnelStep:
type: object
diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts
index 05a0502b..531e58a1 100644
--- a/scripts/generate-openapi.ts
+++ b/scripts/generate-openapi.ts
@@ -1528,17 +1528,16 @@ function buildSchemas(): Record
{
},
FunnelAnalysisRequest: {
type: "object",
- description: "Request for ad-hoc funnel analysis.",
+ description:
+ "Request for ad-hoc funnel analysis. Use query parameters (from, to, preset, timeZone) for time range.",
required: ["steps"],
properties: {
- timeRange: ref("TimeRangeInput"),
steps: {
type: "array",
minItems: 2,
maxItems: 10,
items: ref("FunnelStepInput"),
},
- filters: { type: "array", items: ref("ComplexFilter") },
},
additionalProperties: false,
},
@@ -2243,7 +2242,8 @@ function buildPaths(): OpenAPISpec["paths"] {
get: op({
operationId: "getAnalyticsCrossBreakdown",
summary: "Get analytics cross breakdown",
- description: "Returns a two-dimensional analytics breakdown.",
+ description:
+ "Returns a two-dimensional analytics breakdown. Supports page, referrer, UTM, client, and geo dimensions. Session and event dimensions are not supported.",
tags: ["Analytics"],
parameters: [
...timeParams(),
@@ -2251,12 +2251,12 @@ function buildPaths(): OpenAPISpec["paths"] {
queryParam(
"primary",
{ type: "string", maxLength: 120 },
- "Primary dimension.",
+ "Primary dimension (e.g. client.browser, geo.country, page.path).",
),
queryParam(
"secondary",
{ type: "string", maxLength: 120 },
- "Secondary dimension.",
+ "Secondary dimension (must differ from primary).",
),
queryParam(
"metric",
@@ -2462,6 +2462,14 @@ function buildPaths(): OpenAPISpec["paths"] {
{ type: "string", maxLength: 240 },
"Field path.",
),
+ queryParam(
+ "fieldValueType",
+ {
+ type: "string",
+ enum: ["string", "number", "boolean", "null", "object", "array"],
+ },
+ "Expected value type for the field.",
+ ),
queryParam(
"search",
{ type: "string", maxLength: 160 },
@@ -3266,9 +3274,7 @@ function requestExamplesFor(schemaName: string | null) {
default: {
summary: "Analyze an ad-hoc funnel",
value: {
- timeRange: sampleTimeRange,
steps: funnelExample.steps,
- filters: [{ field: "geo.country", op: "in", value: ["US", "CA"] }],
},
},
},
diff --git a/src/lib/edge/__tests__/api-v1.test.ts b/src/lib/edge/__tests__/api-v1.test.ts
index 91e89eb5..a173fff3 100644
--- a/src/lib/edge/__tests__/api-v1.test.ts
+++ b/src/lib/edge/__tests__/api-v1.test.ts
@@ -1359,6 +1359,14 @@ describe("api v1 gateway", () => {
expect(response.status).toBe(400);
});
+ it("rejects cross-breakdown with unsupported session dimension", async () => {
+ const { response } = await authed(
+ "/api/v1/sites/site-1/analytics/cross-breakdowns?primary=session.entryPath&secondary=client.browser&from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z",
+ [siteMatch("site-1", "Blog")],
+ );
+ expect(response.status).toBe(400);
+ });
+
// ── additional coverage: analytics compare ──────────────────────
it("returns comparison analytics", async () => {
diff --git a/src/lib/edge/__tests__/query-technology-coverage.test.ts b/src/lib/edge/__tests__/query-technology-coverage.test.ts
index f8bef9d2..2b05844d 100644
--- a/src/lib/edge/__tests__/query-technology-coverage.test.ts
+++ b/src/lib/edge/__tests__/query-technology-coverage.test.ts
@@ -13,6 +13,7 @@ import {
SHARE_TREND_OTHER_LABEL,
SHARE_TREND_OTHER_TOKEN,
} from "@/lib/edge/query/core";
+import { clientDimensionDefinition } from "@/lib/edge/query/core";
import {
queryBrowserCrossBreakdownFromD1,
queryBrowserCrossDimensionFromD1,
@@ -20,7 +21,7 @@ import {
queryBrowserTrendFromD1,
queryBrowserVersionBreakdownFromD1,
} from "@/lib/edge/query/technology/browser";
-import { queryClientCrossDimensionFromD1 } from "@/lib/edge/query/technology/client-cross";
+import { queryCrossDimensionFromD1 } from "@/lib/edge/query/technology/client-cross";
import {
queryBrowserRadarFromD1,
queryReferrerRadarFromD1,
@@ -589,15 +590,15 @@ describe("edge technology query coverage", () => {
],
]);
- const result = await queryClientCrossDimensionFromD1(
+ const result = await queryCrossDimensionFromD1(
env,
siteId,
window,
{},
3,
2,
- "browser",
- "deviceType",
+ clientDimensionDefinition("browser"),
+ clientDimensionDefinition("deviceType"),
);
expect(result.columns).toEqual([
@@ -670,27 +671,27 @@ describe("edge technology query coverage", () => {
]);
await expect(
- queryClientCrossDimensionFromD1(
+ queryCrossDimensionFromD1(
noPrimary.env,
siteId,
window,
{},
3,
3,
- "browser",
- "deviceType",
+ clientDimensionDefinition("browser"),
+ clientDimensionDefinition("deviceType"),
),
).resolves.toEqual({ columns: [], rows: [], totalVisitors: 0 });
await expect(
- queryClientCrossDimensionFromD1(
+ queryCrossDimensionFromD1(
noSecondary.env,
siteId,
window,
{},
3,
3,
- "browser",
- "deviceType",
+ clientDimensionDefinition("browser"),
+ clientDimensionDefinition("deviceType"),
),
).resolves.toEqual({ columns: [], rows: [], totalVisitors: 0 });
@@ -738,15 +739,15 @@ describe("edge technology query coverage", () => {
]);
await expect(
- queryClientCrossDimensionFromD1(
+ queryCrossDimensionFromD1(
env,
siteId,
window,
{},
2,
1,
- "deviceType",
- "browser",
+ clientDimensionDefinition("deviceType"),
+ clientDimensionDefinition("browser"),
),
).resolves.toMatchObject({
columns: [{ key: "chrome", label: "Chrome" }],
@@ -1190,15 +1191,15 @@ describe("edge technology query coverage", () => {
]);
await expect(
- queryClientCrossDimensionFromD1(
+ queryCrossDimensionFromD1(
env,
siteId,
window,
{},
3,
3,
- "browser",
- "deviceType",
+ clientDimensionDefinition("browser"),
+ clientDimensionDefinition("deviceType"),
),
).resolves.toEqual({
columns: [
diff --git a/src/lib/edge/__tests__/query-technology-handlers.test.ts b/src/lib/edge/__tests__/query-technology-handlers.test.ts
index 92a1c8a0..a33a200c 100644
--- a/src/lib/edge/__tests__/query-technology-handlers.test.ts
+++ b/src/lib/edge/__tests__/query-technology-handlers.test.ts
@@ -11,8 +11,8 @@ import {
handleBrowserRadar,
handleBrowserTrend,
handleBrowserVersionBreakdown,
- handleClientCrossBreakdown,
handleClientDimensionTrend,
+ handleCrossBreakdown,
handleReferrerDimensionTrend,
handleReferrerRadar,
handleUtmDimensionTrend,
@@ -25,7 +25,7 @@ const queryMocks = vi.hoisted(() => ({
queryBrowserRadarFromD1: vi.fn(),
queryBrowserTrendFromD1: vi.fn(),
queryBrowserVersionBreakdownFromD1: vi.fn(),
- queryClientCrossDimensionFromD1: vi.fn(),
+ queryCrossDimensionFromD1: vi.fn(),
queryClientDimensionTrendFromD1: vi.fn(),
queryReferrerRadarFromD1: vi.fn(),
queryReferrerTrendFromD1: vi.fn(),
@@ -41,7 +41,7 @@ vi.mock("@/lib/edge/query/technology/browser", () => ({
}));
vi.mock("@/lib/edge/query/technology/client-cross", () => ({
- queryClientCrossDimensionFromD1: queryMocks.queryClientCrossDimensionFromD1,
+ queryCrossDimensionFromD1: queryMocks.queryCrossDimensionFromD1,
}));
vi.mock("@/lib/edge/query/technology/radar", () => ({
@@ -95,7 +95,7 @@ function expectNoQueryCalls() {
expect(queryMocks.queryBrowserRadarFromD1).not.toHaveBeenCalled();
expect(queryMocks.queryBrowserTrendFromD1).not.toHaveBeenCalled();
expect(queryMocks.queryBrowserVersionBreakdownFromD1).not.toHaveBeenCalled();
- expect(queryMocks.queryClientCrossDimensionFromD1).not.toHaveBeenCalled();
+ expect(queryMocks.queryCrossDimensionFromD1).not.toHaveBeenCalled();
expect(queryMocks.queryClientDimensionTrendFromD1).not.toHaveBeenCalled();
expect(queryMocks.queryReferrerRadarFromD1).not.toHaveBeenCalled();
expect(queryMocks.queryReferrerTrendFromD1).not.toHaveBeenCalled();
@@ -141,8 +141,11 @@ describe("edge query technology handlers", () => {
["referrer dimension trend", handleReferrerDimensionTrend, {}],
[
"client cross breakdown",
- handleClientCrossBreakdown,
- { primaryDimension: "browser", secondaryDimension: "deviceType" },
+ handleCrossBreakdown,
+ {
+ primaryDimension: "client.browser",
+ secondaryDimension: "client.deviceType",
+ },
],
])("rejects invalid time windows for %s", async (_label, handler, params) => {
const response = await handler(
@@ -536,29 +539,38 @@ describe("edge query technology handlers", () => {
});
it("rejects invalid or duplicate client cross breakdown dimensions", async () => {
- const invalidPrimary = await handleClientCrossBreakdown(
+ const unsupportedPrimary = await handleCrossBreakdown(
env,
siteId,
- testUrl({ primaryDimension: "country", secondaryDimension: "browser" }),
+ testUrl({
+ primaryDimension: "session.entryPath",
+ secondaryDimension: "client.browser",
+ }),
);
- const invalidSecondary = await handleClientCrossBreakdown(
+ const unsupportedSecondary = await handleCrossBreakdown(
env,
siteId,
- testUrl({ primaryDimension: "browser", secondaryDimension: "country" }),
+ testUrl({
+ primaryDimension: "client.browser",
+ secondaryDimension: "event.name",
+ }),
);
- const duplicate = await handleClientCrossBreakdown(
+ const duplicate = await handleCrossBreakdown(
env,
siteId,
- testUrl({ primaryDimension: "browser", secondaryDimension: "browser" }),
+ testUrl({
+ primaryDimension: "client.browser",
+ secondaryDimension: "client.browser",
+ }),
);
- expect(await responseJson(invalidPrimary)).toMatchObject({
+ expect(await responseJson(unsupportedPrimary)).toMatchObject({
ok: false,
- error: { message: "Invalid primary dimension" },
+ error: { message: "Unsupported primary dimension" },
});
- expect(await responseJson(invalidSecondary)).toMatchObject({
+ expect(await responseJson(unsupportedSecondary)).toMatchObject({
ok: false,
- error: { message: "Invalid secondary dimension" },
+ error: { message: "Unsupported secondary dimension" },
});
expect(await responseJson(duplicate)).toMatchObject({
ok: false,
@@ -568,16 +580,14 @@ describe("edge query technology handlers", () => {
});
it("passes parsed client cross breakdown arguments and returns enveloped data", async () => {
- queryMocks.queryClientCrossDimensionFromD1.mockResolvedValue(
- emptyCrossData,
- );
+ queryMocks.queryCrossDimensionFromD1.mockResolvedValue(emptyCrossData);
- const response = await handleClientCrossBreakdown(
+ const response = await handleCrossBreakdown(
env,
siteId,
testUrl({
- primaryDimension: "browser",
- secondaryDimension: "deviceType",
+ primaryDimension: "client.browser",
+ secondaryDimension: "client.deviceType",
primaryLimit: "99",
secondaryLimit: "0",
}),
@@ -588,15 +598,15 @@ describe("edge query technology handlers", () => {
ok: true,
data: emptyCrossData,
});
- expect(queryMocks.queryClientCrossDimensionFromD1).toHaveBeenCalledWith(
+ expect(queryMocks.queryCrossDimensionFromD1).toHaveBeenCalledWith(
env,
siteId,
parsedWindow(),
expect.any(Object),
12,
1,
- "browser",
- "deviceType",
+ expect.objectContaining({ fallbackKeyBase: "browser" }),
+ expect.objectContaining({ fallbackKeyBase: "device" }),
);
});
});
diff --git a/src/lib/edge/__tests__/query.test.ts b/src/lib/edge/__tests__/query.test.ts
index 0a938b54..0d0ea2bc 100644
--- a/src/lib/edge/__tests__/query.test.ts
+++ b/src/lib/edge/__tests__/query.test.ts
@@ -1429,14 +1429,14 @@ describe("edge query handlers", () => {
const clientCross = await privateQuery(
privatePath(
"client-cross-breakdown",
- "primaryDimension=browser&secondaryDimension=deviceType",
+ "primaryDimension=client.browser&secondaryDimension=client.deviceType",
),
env,
);
const invalidClientCross = await privateQuery(
privatePath(
"client-cross-breakdown",
- "primaryDimension=browser&secondaryDimension=browser",
+ "primaryDimension=client.browser&secondaryDimension=client.browser",
),
env,
);
diff --git a/src/lib/edge/api-v1-helpers.ts b/src/lib/edge/api-v1-helpers.ts
index b516c303..2394a9e7 100644
--- a/src/lib/edge/api-v1-helpers.ts
+++ b/src/lib/edge/api-v1-helpers.ts
@@ -454,6 +454,28 @@ export function validateDimension(
});
}
+const UNSUPPORTED_CROSS_BREAKDOWN = new Set([
+ "session.entryPath",
+ "session.exitPath",
+ "event.name",
+]);
+
+export function validateCrossBreakdownDimension(
+ value: string,
+): AnalyticsDimension | Response {
+ const base = validateDimension(value);
+ if (base instanceof Response) return base;
+ if (UNSUPPORTED_CROSS_BREAKDOWN.has(value)) {
+ return jsonError(
+ "validation_failed",
+ "Dimension not supported for cross-breakdowns",
+ 400,
+ { dimension: value },
+ );
+ }
+ return base;
+}
+
export function parseFilter(url: URL): Record | Response {
const filters: Record = {};
for (const [key, value] of url.searchParams.entries()) {
diff --git a/src/lib/edge/api-v1.ts b/src/lib/edge/api-v1.ts
index 574b8450..4cb25ccc 100644
--- a/src/lib/edge/api-v1.ts
+++ b/src/lib/edge/api-v1.ts
@@ -47,6 +47,7 @@ import {
parseTimeRange,
requireScope,
TIME_PRESETS,
+ validateCrossBreakdownDimension,
validateDimension,
} from "./api-v1-helpers";
import {
@@ -1364,8 +1365,10 @@ async function handleAnalytics(
}
if (resource === "cross-breakdowns") {
if (request.method !== "GET") return methodNotAllowed(request);
- const primary = validateDimension(url.searchParams.get("primary") || "");
- const secondary = validateDimension(
+ const primary = validateCrossBreakdownDimension(
+ url.searchParams.get("primary") || "",
+ );
+ const secondary = validateCrossBreakdownDimension(
url.searchParams.get("secondary") || "",
);
if (primary instanceof Response) return primary;
diff --git a/src/lib/edge/query/core-dimensions.ts b/src/lib/edge/query/core-dimensions.ts
index 042fc2a7..1616690c 100644
--- a/src/lib/edge/query/core-dimensions.ts
+++ b/src/lib/edge/query/core-dimensions.ts
@@ -1,3 +1,5 @@
+import { browserEngineCaseSql } from "@/lib/browser-engine";
+
import {
type ClientDimensionKey,
DIRECT_REFERRER_FILTER_VALUE,
@@ -154,3 +156,104 @@ export function regionValueExpr(): string {
export function cityValueExpr(): string {
return "CASE WHEN TRIM(country) = '' AND TRIM(region_code) = '' AND TRIM(region) = '' AND TRIM(city) = '' THEN '' ELSE TRIM(country) || '::' || CASE WHEN TRIM(region_code) != '' THEN TRIM(region_code) ELSE TRIM(region) END || '::' || TRIM(region) || '::' || TRIM(city) END";
}
+
+export function resolveCrossBreakdownDimension(
+ dimension: string,
+): { labelExpr: string; fallbackKeyBase: string } | null {
+ // ── page ──────────────────────────────────────────────────────────────
+ if (dimension === "page.path")
+ return {
+ labelExpr: "TRIM(COALESCE(pathname, ''))",
+ fallbackKeyBase: "page",
+ };
+ if (dimension === "page.title")
+ return { labelExpr: "TRIM(COALESCE(title, ''))", fallbackKeyBase: "title" };
+ if (dimension === "page.hostname")
+ return {
+ labelExpr: "TRIM(COALESCE(hostname, ''))",
+ fallbackKeyBase: "hostname",
+ };
+ if (dimension === "page.query")
+ return {
+ labelExpr: "TRIM(COALESCE(query_string, ''))",
+ fallbackKeyBase: "query",
+ };
+ if (dimension === "page.hash")
+ return {
+ labelExpr: "TRIM(COALESCE(hash_fragment, ''))",
+ fallbackKeyBase: "hash",
+ };
+
+ // ── session (requires session-level aggregation, not supported) ───────
+ if (dimension === "session.entryPath" || dimension === "session.exitPath")
+ return null;
+
+ // ── referrer ──────────────────────────────────────────────────────────
+ if (dimension === "referrer.domain")
+ return referrerDomainDimensionDefinition();
+ if (dimension === "referrer.url")
+ return {
+ labelExpr: "TRIM(COALESCE(referrer_url, ''))",
+ fallbackKeyBase: "referrer-url",
+ };
+
+ // ── utm ───────────────────────────────────────────────────────────────
+ if (dimension === "utm.source") return utmDimensionDefinition("source");
+ if (dimension === "utm.medium") return utmDimensionDefinition("medium");
+ if (dimension === "utm.campaign") return utmDimensionDefinition("campaign");
+ if (dimension === "utm.term") return utmDimensionDefinition("term");
+ if (dimension === "utm.content") return utmDimensionDefinition("content");
+
+ // ── client ────────────────────────────────────────────────────────────
+ if (dimension === "client.browser")
+ return clientDimensionDefinition("browser");
+ if (dimension === "client.browserVersion")
+ return {
+ labelExpr: "TRIM(COALESCE(browser_version, ''))",
+ fallbackKeyBase: "browser-version",
+ };
+ if (dimension === "client.browserEngine")
+ return {
+ labelExpr: browserEngineCaseSql("browser", "os"),
+ fallbackKeyBase: "engine",
+ };
+ if (dimension === "client.os")
+ return clientDimensionDefinition("operatingSystem");
+ if (dimension === "client.osVersion")
+ return clientDimensionDefinition("osVersion");
+ if (dimension === "client.deviceType")
+ return clientDimensionDefinition("deviceType");
+ if (dimension === "client.language")
+ return clientDimensionDefinition("language");
+ if (dimension === "client.screenSize")
+ return clientDimensionDefinition("screenSize");
+
+ // ── geo ───────────────────────────────────────────────────────────────
+ if (dimension === "geo.country")
+ return {
+ labelExpr: "TRIM(COALESCE(country, ''))",
+ fallbackKeyBase: "country",
+ };
+ if (dimension === "geo.region")
+ return { labelExpr: regionValueExpr(), fallbackKeyBase: "region" };
+ if (dimension === "geo.city")
+ return { labelExpr: cityValueExpr(), fallbackKeyBase: "city" };
+ if (dimension === "geo.continent")
+ return {
+ labelExpr: "TRIM(COALESCE(continent, ''))",
+ fallbackKeyBase: "continent",
+ };
+ if (dimension === "geo.timeZone")
+ return {
+ labelExpr: "TRIM(COALESCE(timezone, ''))",
+ fallbackKeyBase: "timezone",
+ };
+ if (dimension === "geo.organization")
+ return {
+ labelExpr: "TRIM(COALESCE(as_organization, ''))",
+ fallbackKeyBase: "organization",
+ };
+
+ // ── event (requires events table join, not supported) ─────────────────
+ return null;
+}
diff --git a/src/lib/edge/query/router.ts b/src/lib/edge/query/router.ts
index bc68e325..eeba29ec 100644
--- a/src/lib/edge/query/router.ts
+++ b/src/lib/edge/query/router.ts
@@ -46,8 +46,8 @@ import {
handleBrowserRadar,
handleBrowserTrend,
handleBrowserVersionBreakdown,
- handleClientCrossBreakdown,
handleClientDimensionTrend,
+ handleCrossBreakdown,
handleReferrerDimensionTrend,
handleReferrerRadar,
handleUtmDimensionTrend,
@@ -159,7 +159,7 @@ export async function routeQuery(
return handleUtmDimensionTrend(env, siteId, url, ctx);
}
if (pathname === "client-cross-breakdown") {
- return handleClientCrossBreakdown(env, siteId, url, ctx);
+ return handleCrossBreakdown(env, siteId, url, ctx);
}
if (pathname === "utm-source") {
return handleDimension(
diff --git a/src/lib/edge/query/technology/client-cross.ts b/src/lib/edge/query/technology/client-cross.ts
index 54af8b6f..16e8d81e 100644
--- a/src/lib/edge/query/technology/client-cross.ts
+++ b/src/lib/edge/query/technology/client-cross.ts
@@ -3,7 +3,6 @@ import type {
BrowserCrossBreakdownDimensionRow,
BrowserCrossBreakdownItemRow,
ClientCrossAggregateRow,
- ClientDimensionKey,
DashboardFilters,
QueryWindow,
} from "@/lib/edge/query/core";
@@ -13,7 +12,6 @@ import {
CLIENT_CROSS_OTHER_PRIMARY_TOKEN,
CLIENT_CROSS_OTHER_SECONDARY_TOKEN,
CLIENT_CROSS_UNKNOWN_TOKEN,
- clientDimensionDefinition,
queryD1All,
SHARE_TREND_OTHER_LABEL,
shareTrendSeriesKey,
@@ -21,23 +19,26 @@ import {
} from "@/lib/edge/query/core";
import type { Env } from "@/lib/edge/types";
-export async function queryClientCrossDimensionFromD1(
+interface DimensionDefinition {
+ labelExpr: string;
+ fallbackKeyBase: string;
+}
+
+export async function queryCrossDimensionFromD1(
env: Env,
siteId: string,
window: QueryWindow,
filters: DashboardFilters,
primaryLimit: number,
secondaryLimit: number,
- primaryDimension: ClientDimensionKey,
- secondaryDimension: ClientDimensionKey,
+ primaryDimension: DimensionDefinition,
+ secondaryDimension: DimensionDefinition,
): Promise {
const filter = buildVisitFilterSql(filters);
const normalizedPrimaryLimit = Math.min(Math.max(1, primaryLimit), 12);
const normalizedSecondaryLimit = Math.min(Math.max(1, secondaryLimit), 8);
- const primaryDefinition = clientDimensionDefinition(primaryDimension);
- const secondaryDefinition = clientDimensionDefinition(secondaryDimension);
- const primaryExpr = primaryDefinition.labelExpr;
- const normalizedSecondaryExpr = `CASE WHEN ${secondaryDefinition.labelExpr} != '' THEN ${secondaryDefinition.labelExpr} ELSE '${CLIENT_CROSS_UNKNOWN_TOKEN}' END`;
+ const primaryExpr = primaryDimension.labelExpr;
+ const normalizedSecondaryExpr = `CASE WHEN ${secondaryDimension.labelExpr} != '' THEN ${secondaryDimension.labelExpr} ELSE '${CLIENT_CROSS_UNKNOWN_TOKEN}' END`;
const topPrimarySql = `
WITH
@@ -269,7 +270,7 @@ ORDER BY primaryValue ASC, secondaryValue ASC
key: shareTrendSeriesKey(
row.value,
columnKeySet,
- secondaryDefinition.fallbackKeyBase,
+ secondaryDimension.fallbackKeyBase,
),
label: row.value,
views: row.views,
@@ -310,7 +311,7 @@ ORDER BY primaryValue ASC, secondaryValue ASC
key: shareTrendSeriesKey(
row.value,
rowKeySet,
- primaryDefinition.fallbackKeyBase,
+ primaryDimension.fallbackKeyBase,
),
label: row.value,
views: row.views,
diff --git a/src/lib/edge/query/technology/handlers.ts b/src/lib/edge/query/technology/handlers.ts
index b5b2720f..c3b95e8d 100644
--- a/src/lib/edge/query/technology/handlers.ts
+++ b/src/lib/edge/query/technology/handlers.ts
@@ -6,6 +6,7 @@ import {
parseLimit,
parseQueryLimit,
parseWindow,
+ resolveCrossBreakdownDimension,
type ResponseContext,
} from "@/lib/edge/query/core";
import type { Env } from "@/lib/edge/types";
@@ -17,7 +18,7 @@ import {
queryBrowserTrendFromD1,
queryBrowserVersionBreakdownFromD1,
} from "./browser";
-import { queryClientCrossDimensionFromD1 } from "./client-cross";
+import { queryCrossDimensionFromD1 } from "./client-cross";
import { parseClientDimensionKey, parseUtmDimensionKey } from "./parsers";
import { queryBrowserRadarFromD1, queryReferrerRadarFromD1 } from "./radar";
import {
@@ -301,21 +302,19 @@ export async function handleReferrerDimensionTrend(
});
}
-export async function handleClientCrossBreakdown(
+export async function handleCrossBreakdown(
env: Env,
siteId: string,
url: URL,
ctx?: ResponseContext,
): Promise {
- const primaryDimension = parseClientDimensionKey(
- url.searchParams.get("primaryDimension"),
- );
- if (!primaryDimension) return badRequest("Invalid primary dimension");
- const secondaryDimension = parseClientDimensionKey(
- url.searchParams.get("secondaryDimension"),
- );
- if (!secondaryDimension) return badRequest("Invalid secondary dimension");
- if (primaryDimension === secondaryDimension) {
+ const primaryRaw = url.searchParams.get("primaryDimension") || "";
+ const secondaryRaw = url.searchParams.get("secondaryDimension") || "";
+ const primaryDimension = resolveCrossBreakdownDimension(primaryRaw);
+ if (!primaryDimension) return badRequest("Unsupported primary dimension");
+ const secondaryDimension = resolveCrossBreakdownDimension(secondaryRaw);
+ if (!secondaryDimension) return badRequest("Unsupported secondary dimension");
+ if (primaryRaw === secondaryRaw) {
return badRequest("Primary and secondary dimensions must differ");
}
const window = parseWindow(url);
@@ -323,7 +322,7 @@ export async function handleClientCrossBreakdown(
const filters = parseFilters(url);
const primaryLimit = parseQueryLimit(url, "primaryLimit", 5, 1, 12);
const secondaryLimit = parseQueryLimit(url, "secondaryLimit", 6, 1, 8);
- const data = await queryClientCrossDimensionFromD1(
+ const data = await queryCrossDimensionFromD1(
env,
siteId,
window,
From 2a0fa313df6c852642212c5f00f193f043cc7e6e Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 17:33:55 +0800
Subject: [PATCH 03/40] fix: add missing timeParams to funnels/analysis OpenAPI
spec
The POST /funnels/analysis endpoint accepts from/to/preset/timeZone
query params (used in the handler via parseTimeRange), but the OpenAPI
spec did not declare them. This would cause SDK generators to omit
time range arguments for ad-hoc funnel analysis.
---
docs/openapi.json | 36 ++++++++++++++++++++++++++++++++++++
docs/openapi.yaml | 31 +++++++++++++++++++++++++++++++
scripts/generate-openapi.ts | 2 +-
3 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/docs/openapi.json b/docs/openapi.json
index fe5a8059..af14a767 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -4801,6 +4801,42 @@
"parameters": [
{
"$ref": "#/components/parameters/siteId"
+ },
+ {
+ "name": "from",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ },
+ {
+ "name": "to",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ },
+ {
+ "name": "preset",
+ "in": "query",
+ "schema": {
+ "$ref": "#/components/schemas/Preset"
+ },
+ "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ },
+ {
+ "name": "timeZone",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "maxLength": 80,
+ "default": "UTC"
+ },
+ "description": "IANA time zone used to resolve presets. Defaults to UTC."
}
],
"post": {
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 5d8e356e..a4fb1647 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -3349,6 +3349,37 @@ paths:
/api/v1/sites/{siteId}/funnels/analysis:
parameters:
- *a3
+ - name: from
+ in: query
+ schema:
+ type: string
+ format: date-time
+ description:
+ Inclusive ISO 8601 start time. If from, to, and preset are omitted,
+ analytics endpoints default to the last 7 days ending at request time.
+ - name: to
+ in: query
+ schema:
+ type: string
+ format: date-time
+ description:
+ Exclusive ISO 8601 end time. If from, to, and preset are omitted,
+ analytics endpoints default to the last 7 days ending at request time.
+ - name: preset
+ in: query
+ schema:
+ $ref: "#/components/schemas/Preset"
+ description:
+ Named time range preset. Mutually exclusive with from and to. If
+ from, to, and preset are omitted, analytics endpoints default to the
+ last 7 days ending at request time.
+ - name: timeZone
+ in: query
+ schema:
+ type: string
+ maxLength: 80
+ default: UTC
+ description: IANA time zone used to resolve presets. Defaults to UTC.
post:
operationId: analyzeFunnel
summary: Analyze funnel
diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts
index 531e58a1..0759cc10 100644
--- a/scripts/generate-openapi.ts
+++ b/scripts/generate-openapi.ts
@@ -2632,7 +2632,7 @@ function buildPaths(): OpenAPISpec["paths"] {
}),
},
"/api/v1/sites/{siteId}/funnels/analysis": {
- parameters: [siteParam],
+ parameters: [siteParam, ...timeParams()],
post: op({
operationId: "analyzeFunnel",
summary: "Analyze funnel",
From 9551b78d25225fb16cb4577d08e6e5ec6c24a18b Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 17:46:57 +0800
Subject: [PATCH 04/40] fix: align OpenAPI contract metadata
Document ISO 8601 timestamp semantics and upstream 429 behavior at the top level.
Add required-scope extensions, reusable parameters, opaque visitor/session IDs, and constrained complex filters.
Regenerate OpenAPI artifacts and strengthen contract checks against future drift.
---
docs/openapi.json | 1886 ++++++++-------------------
docs/openapi.yaml | 1926 ++++++++--------------------
scripts/check-openapi-contract.mjs | 91 +-
scripts/generate-openapi.ts | 343 +++--
scripts/skills-template.json | 23 +-
5 files changed, 1444 insertions(+), 2825 deletions(-)
diff --git a/docs/openapi.json b/docs/openapi.json
index af14a767..b2855f09 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "InsightFlare API",
- "description": "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All API times are ISO 8601 strings and analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.",
+ "description": "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with `Ms`. Fields ending with `Ms` represent millisecond values, such as durations or Unix timestamps depending on context. Analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.\n\nThis OpenAPI document describes the behavior of the InsightFlare origin API. Depending on deployment configuration, upstream infrastructure, proxies, gateways, or edge providers may return additional HTTP responses before requests reach the API origin, such as 429 Too Many Requests. These responses are outside the standard API error envelope and are not part of the stable API contract.",
"version": "1.0.0",
"contact": {
"name": "InsightFlare",
@@ -13,6 +13,11 @@
"url": "https://github.com/ravelloh/InsightFlare/blob/main/LICENSE"
}
},
+ "externalDocs": {
+ "description": "InsightFlare API documentation",
+ "url": "https://insight.ravelloh.com/docs"
+ },
+ "x-possible-upstream-responses": [429],
"servers": [
{
"url": "https://insight.ravelloh.com",
@@ -120,7 +125,8 @@
"500": {
"$ref": "#/components/responses/InternalError"
}
- }
+ },
+ "x-required-scopes": []
}
},
"/collect": {
@@ -202,7 +208,8 @@
"413": {
"$ref": "#/components/responses/PayloadTooLarge"
}
- }
+ },
+ "x-required-scopes": []
}
},
"/api/v1": {
@@ -252,7 +259,8 @@
"500": {
"$ref": "#/components/responses/InternalError"
}
- }
+ },
+ "x-required-scopes": []
}
},
"/api/v1/token": {
@@ -307,7 +315,8 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
}
- }
+ },
+ "x-required-scopes": []
}
},
"/api/v1/token/check": {
@@ -376,7 +385,8 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
}
- }
+ },
+ "x-required-scopes": []
}
},
"/api/v1/capabilities": {
@@ -440,7 +450,8 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
}
- }
+ },
+ "x-required-scopes": []
}
},
"/api/v1/team": {
@@ -484,7 +495,8 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
}
- }
+ },
+ "x-required-scopes": ["site:read"]
}
},
"/api/v1/team/usage": {
@@ -521,7 +533,8 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
}
- }
+ },
+ "x-required-scopes": ["site:read"]
}
},
"/api/v1/team/analytics/overview": {
@@ -532,73 +545,22 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "metrics",
- "in": "query",
- "style": "form",
- "explode": false,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events"
- ]
- }
- },
- "description": "Comma-separated metrics to include."
+ "$ref": "#/components/parameters/MetricsQueryParam"
}
],
"responses": {
@@ -647,7 +609,8 @@
"403": {
"$ref": "#/components/responses/Forbidden"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/team/analytics/timeseries": {
@@ -658,83 +621,25 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "interval",
- "in": "query",
- "schema": {
- "type": "string",
- "enum": ["minute", "hour", "day", "week", "month"],
- "default": "day"
- },
- "description": "Time bucket granularity."
+ "$ref": "#/components/parameters/IntervalQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "metrics",
- "in": "query",
- "style": "form",
- "explode": false,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events"
- ]
- }
- },
- "description": "Comma-separated metrics to include."
+ "$ref": "#/components/parameters/MetricsQueryParam"
}
],
"responses": {
@@ -784,7 +689,8 @@
"403": {
"$ref": "#/components/responses/Forbidden"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/team/analytics/sites": {
@@ -795,63 +701,19 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "metrics",
- "in": "query",
- "style": "form",
- "explode": false,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events"
- ]
- }
- },
- "description": "Comma-separated metrics to include."
+ "$ref": "#/components/parameters/MetricsQueryParam"
}
],
"responses": {
@@ -906,13 +768,14 @@
"403": {
"$ref": "#/components/responses/Forbidden"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/team/analytics/breakdowns/{dimension}": {
"parameters": [
{
- "$ref": "#/components/parameters/dimension"
+ "$ref": "#/components/parameters/DimensionPathParam"
}
],
"get": {
@@ -922,73 +785,22 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "metrics",
- "in": "query",
- "style": "form",
- "explode": false,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events"
- ]
- }
- },
- "description": "Comma-separated metrics to include."
+ "$ref": "#/components/parameters/MetricsQueryParam"
},
{
"name": "limit",
@@ -1051,7 +863,8 @@
"403": {
"$ref": "#/components/responses/Forbidden"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites": {
@@ -1113,7 +926,8 @@
"403": {
"$ref": "#/components/responses/Forbidden"
}
- }
+ },
+ "x-required-scopes": ["site:read"]
},
"post": {
"operationId": "createSite",
@@ -1211,13 +1025,14 @@
"409": {
"$ref": "#/components/responses/Conflict"
}
- }
+ },
+ "x-required-scopes": ["site:write"]
}
},
"/api/v1/sites/{siteId}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -1279,7 +1094,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site:read"]
},
"patch": {
"operationId": "updateSite",
@@ -1368,7 +1184,8 @@
"409": {
"$ref": "#/components/responses/Conflict"
}
- }
+ },
+ "x-required-scopes": ["site:write"]
},
"delete": {
"operationId": "deleteSite",
@@ -1388,13 +1205,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site:write"]
}
},
"/api/v1/sites/{siteId}/tracking": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -1445,7 +1263,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site_config:read"]
},
"patch": {
"operationId": "updateTrackingSettings",
@@ -1521,13 +1340,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site_config:write"]
}
},
"/api/v1/sites/{siteId}/tracking/script": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -1571,13 +1391,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site_config:read"]
}
},
"/api/v1/sites/{siteId}/privacy": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -1623,7 +1444,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site_config:read"]
},
"patch": {
"operationId": "updatePrivacySettings",
@@ -1690,13 +1512,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site_config:write"]
}
},
"/api/v1/sites/{siteId}/sharing": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -1739,7 +1562,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site_config:read"]
},
"patch": {
"operationId": "updateSharingSettings",
@@ -1806,13 +1630,14 @@
"409": {
"$ref": "#/components/responses/Conflict"
}
- }
+ },
+ "x-required-scopes": ["site_config:write"]
}
},
"/api/v1/sites/{siteId}/analytics/schema": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -1835,28 +1660,48 @@
"data": {
"metrics": [
{
+ "id": "views",
"key": "views",
"label": "Views",
"type": "integer",
- "description": "Total page views."
+ "description": "Total page views.",
+ "unit": "count",
+ "aggregation": "sum",
+ "filterable": false,
+ "sortable": true
},
{
+ "id": "bounceRate",
"key": "bounceRate",
"label": "Bounce rate",
"type": "rate",
- "description": "Single-page session rate as a 0-1 ratio."
+ "description": "Single-page session rate as a 0-1 ratio.",
+ "unit": "ratio",
+ "aggregation": "ratio",
+ "filterable": false,
+ "sortable": true
}
],
"dimensions": [
{
+ "id": "page.path",
"key": "page.path",
"label": "Page path",
- "type": "string"
+ "description": "Normalized page path from the tracked URL.",
+ "type": "string",
+ "filterable": true,
+ "groupable": true,
+ "sortable": true
},
{
+ "id": "geo.country",
"key": "geo.country",
"label": "Country",
- "type": "string"
+ "description": "Visitor country inferred from request metadata.",
+ "type": "string",
+ "filterable": true,
+ "groupable": true,
+ "sortable": true
}
],
"filters": ["page.path", "geo.country"],
@@ -1891,13 +1736,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/analytics/overview": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -1907,73 +1753,22 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "metrics",
- "in": "query",
- "style": "form",
- "explode": false,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events"
- ]
- }
- },
- "description": "Comma-separated metrics to include."
+ "$ref": "#/components/parameters/MetricsQueryParam"
}
],
"responses": {
@@ -2025,13 +1820,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/analytics/timeseries": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -2041,83 +1837,25 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "interval",
- "in": "query",
- "schema": {
- "type": "string",
- "enum": ["minute", "hour", "day", "week", "month"],
- "default": "day"
- },
- "description": "Time bucket granularity."
+ "$ref": "#/components/parameters/IntervalQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "metrics",
- "in": "query",
- "style": "form",
- "explode": false,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events"
- ]
- }
- },
- "description": "Comma-separated metrics to include."
+ "$ref": "#/components/parameters/MetricsQueryParam"
}
],
"responses": {
@@ -2170,16 +1908,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/analytics/breakdowns/{dimension}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/dimension"
+ "$ref": "#/components/parameters/DimensionPathParam"
}
],
"get": {
@@ -2189,73 +1928,22 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "metrics",
- "in": "query",
- "style": "form",
- "explode": false,
- "schema": {
- "type": "array",
- "items": {
- "type": "string",
- "enum": [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events"
- ]
- }
- },
- "description": "Comma-separated metrics to include."
+ "$ref": "#/components/parameters/MetricsQueryParam"
},
{
"name": "limit",
@@ -2322,13 +2010,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/analytics/cross-breakdowns": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -2338,50 +2027,19 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
"name": "primary",
@@ -2466,13 +2124,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/analytics/compare": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -2482,50 +2141,19 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
"name": "compare",
@@ -2598,13 +2226,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/analytics/explore": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"post": {
@@ -2706,13 +2335,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/analytics/retention/cohorts": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -2722,60 +2352,22 @@
"tags": ["Analytics"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "interval",
- "in": "query",
- "schema": {
- "type": "string",
- "enum": ["minute", "hour", "day", "week", "month"],
- "default": "day"
- },
- "description": "Time bucket granularity."
+ "$ref": "#/components/parameters/IntervalQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
}
],
"responses": {
@@ -2833,13 +2425,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/event-types": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -2849,40 +2442,16 @@
"tags": ["Events"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
"name": "limit",
@@ -2950,16 +2519,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/event-types/{eventName}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/eventName"
+ "$ref": "#/components/parameters/EventNamePathParam"
}
],
"get": {
@@ -2969,50 +2539,19 @@
"tags": ["Events"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "interval",
- "in": "query",
- "schema": {
- "type": "string",
- "enum": ["minute", "hour", "day", "week", "month"],
- "default": "day"
- },
- "description": "Time bucket granularity."
+ "$ref": "#/components/parameters/IntervalQueryParam"
}
],
"responses": {
@@ -3075,13 +2614,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/events": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -3091,70 +2631,25 @@
"tags": ["Events"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "limit",
- "in": "query",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 1000,
- "default": 100
- },
- "description": "Maximum number of results."
+ "$ref": "#/components/parameters/LimitQueryParam"
},
{
- "name": "cursor",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 512
- },
- "description": "Opaque pagination cursor from the previous response."
+ "$ref": "#/components/parameters/CursorQueryParam"
},
{
"name": "sort",
@@ -3227,13 +2722,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/events/summary": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -3243,50 +2739,19 @@
"tags": ["Events"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
}
],
"responses": {
@@ -3330,13 +2795,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/events/timeseries": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -3346,60 +2812,22 @@
"tags": ["Events"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "interval",
- "in": "query",
- "schema": {
- "type": "string",
- "enum": ["minute", "hour", "day", "week", "month"],
- "default": "day"
- },
- "description": "Time bucket granularity."
+ "$ref": "#/components/parameters/IntervalQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
"name": "eventName",
@@ -3461,13 +2889,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/events/search": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"post": {
@@ -3565,16 +2994,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/events/{eventId}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/eventId"
+ "$ref": "#/components/parameters/EventIdPathParam"
}
],
"get": {
@@ -3624,13 +3054,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/event-fields/values": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -3640,40 +3071,16 @@
"tags": ["Events"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
"name": "eventName",
@@ -3765,13 +3172,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/visitors": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -3781,70 +3189,25 @@
"tags": ["Visitors"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "limit",
- "in": "query",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 1000,
- "default": 100
- },
- "description": "Maximum number of results."
+ "$ref": "#/components/parameters/LimitQueryParam"
},
{
- "name": "cursor",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 512
- },
- "description": "Opaque pagination cursor from the previous response."
+ "$ref": "#/components/parameters/CursorQueryParam"
},
{
"name": "sort",
@@ -3919,16 +3282,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/visitors/{visitorId}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/visitorId"
+ "$ref": "#/components/parameters/VisitorIdPathParam"
}
],
"get": {
@@ -3980,16 +3344,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/visitors/{visitorId}/sessions": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/visitorId"
+ "$ref": "#/components/parameters/VisitorIdPathParam"
}
],
"get": {
@@ -3999,60 +3364,22 @@
"tags": ["Visitors"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "limit",
- "in": "query",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 1000,
- "default": 100
- },
- "description": "Maximum number of results."
+ "$ref": "#/components/parameters/LimitQueryParam"
},
{
- "name": "cursor",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 512
- },
- "description": "Opaque pagination cursor from the previous response."
+ "$ref": "#/components/parameters/CursorQueryParam"
}
],
"responses": {
@@ -4108,16 +3435,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/visitors/{visitorId}/events": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/visitorId"
+ "$ref": "#/components/parameters/VisitorIdPathParam"
}
],
"get": {
@@ -4127,60 +3455,22 @@
"tags": ["Visitors"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "limit",
- "in": "query",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 1000,
- "default": 100
- },
- "description": "Maximum number of results."
+ "$ref": "#/components/parameters/LimitQueryParam"
},
{
- "name": "cursor",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 512
- },
- "description": "Opaque pagination cursor from the previous response."
+ "$ref": "#/components/parameters/CursorQueryParam"
}
],
"responses": {
@@ -4235,13 +3525,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/sessions": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -4251,70 +3542,25 @@
"tags": ["Sessions"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
- "name": "limit",
- "in": "query",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 1000,
- "default": 100
- },
- "description": "Maximum number of results."
+ "$ref": "#/components/parameters/LimitQueryParam"
},
{
- "name": "cursor",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 512
- },
- "description": "Opaque pagination cursor from the previous response."
+ "$ref": "#/components/parameters/CursorQueryParam"
},
{
"name": "sort",
@@ -4388,16 +3634,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/sessions/{sessionId}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/sessionId"
+ "$ref": "#/components/parameters/SessionIdPathParam"
}
],
"get": {
@@ -4448,16 +3695,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/sessions/{sessionId}/events": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/sessionId"
+ "$ref": "#/components/parameters/SessionIdPathParam"
}
],
"get": {
@@ -4466,61 +3714,23 @@
"description": "Lists events for a session.",
"tags": ["Sessions"],
"parameters": [
- {
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ {
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "limit",
- "in": "query",
- "schema": {
- "type": "integer",
- "minimum": 1,
- "maximum": 1000,
- "default": 100
- },
- "description": "Maximum number of results."
+ "$ref": "#/components/parameters/LimitQueryParam"
},
{
- "name": "cursor",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 512
- },
- "description": "Opaque pagination cursor from the previous response."
+ "$ref": "#/components/parameters/CursorQueryParam"
}
],
"responses": {
@@ -4575,13 +3785,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/funnels": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -4591,40 +3802,16 @@
"tags": ["Funnels"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
}
],
"responses": {
@@ -4687,7 +3874,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
},
"post": {
"operationId": "createFunnel",
@@ -4794,49 +3982,26 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site:write"]
}
},
"/api/v1/sites/{siteId}/funnels/analysis": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
}
],
"post": {
@@ -4946,16 +4111,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/funnels/{funnelId}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/funnelId"
+ "$ref": "#/components/parameters/FunnelIdPathParam"
}
],
"get": {
@@ -5018,7 +4184,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
},
"patch": {
"operationId": "updateFunnel",
@@ -5113,7 +4280,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site:write"]
},
"delete": {
"operationId": "deleteFunnel",
@@ -5133,16 +4301,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["site:write"]
}
},
"/api/v1/sites/{siteId}/funnels/{funnelId}/analysis": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/funnelId"
+ "$ref": "#/components/parameters/FunnelIdPathParam"
}
],
"get": {
@@ -5152,40 +4321,16 @@
"tags": ["Funnels"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
}
],
"responses": {
@@ -5287,13 +4432,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/performance/summary": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -5303,50 +4449,19 @@
"tags": ["Performance"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
}
],
"responses": {
@@ -5390,13 +4505,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/performance/timeseries": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -5406,60 +4522,22 @@
"tags": ["Performance"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "interval",
- "in": "query",
- "schema": {
- "type": "string",
- "enum": ["minute", "hour", "day", "week", "month"],
- "default": "day"
- },
- "description": "Time bucket granularity."
+ "$ref": "#/components/parameters/IntervalQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
}
],
"responses": {
@@ -5513,16 +4591,17 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/performance/breakdowns/{dimension}": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
},
{
- "$ref": "#/components/parameters/dimension"
+ "$ref": "#/components/parameters/DimensionPathParam"
}
],
"get": {
@@ -5532,50 +4611,19 @@
"tags": ["Performance"],
"parameters": [
{
- "name": "from",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/FromQueryParam"
},
{
- "name": "to",
- "in": "query",
- "schema": {
- "type": "string",
- "format": "date-time"
- },
- "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/ToQueryParam"
},
{
- "name": "preset",
- "in": "query",
- "schema": {
- "$ref": "#/components/schemas/Preset"
- },
- "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ "$ref": "#/components/parameters/PresetQueryParam"
},
{
- "name": "timeZone",
- "in": "query",
- "schema": {
- "type": "string",
- "maxLength": 80,
- "default": "UTC"
- },
- "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ "$ref": "#/components/parameters/TimeZoneQueryParam"
},
{
- "name": "filter",
- "in": "query",
- "style": "deepObject",
- "explode": true,
- "schema": {
- "$ref": "#/components/schemas/FilterObject"
- },
- "description": "Simple equality filters as filter[field]=value."
+ "$ref": "#/components/parameters/FilterQueryParam"
},
{
"name": "metric",
@@ -5629,13 +4677,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/realtime/active-visitors": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -5677,13 +4726,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/realtime/events": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -5750,13 +4800,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/realtime/sessions": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -5824,13 +4875,14 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/sites/{siteId}/realtime/snapshot": {
"parameters": [
{
- "$ref": "#/components/parameters/siteId"
+ "$ref": "#/components/parameters/SiteIdPathParam"
}
],
"get": {
@@ -5899,7 +4951,8 @@
"404": {
"$ref": "#/components/responses/NotFound"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
},
"/api/v1/batch": {
@@ -6018,7 +5071,8 @@
"401": {
"$ref": "#/components/responses/Unauthorized"
}
- }
+ },
+ "x-required-scopes": ["analytics:read"]
}
}
},
@@ -6269,7 +5323,7 @@
},
"ComplexFilter": {
"type": "object",
- "description": "Advanced filter rule for explore and search endpoints. Operators: eq equals; neq does not equal; in is one of; notIn is not one of; contains includes substring; startsWith/endsWith match string edges; gt/gte/lt/lte compare ordered values; exists/notExists ignore value.",
+ "description": "Advanced filter rule for explore and search endpoints. For eq, neq, contains, startsWith, and endsWith, use a scalar value. For in and notIn, use an array value. For gt, gte, lt, and lte, use a number or ISO 8601 date-time string depending on the field. For exists and notExists, value may be omitted.",
"required": ["field", "op"],
"properties": {
"field": {
@@ -6296,45 +5350,102 @@
"notExists"
]
},
- "value": {}
+ "value": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ }
+ ]
+ }
}
},
"MetricDefinition": {
"type": "object",
"description": "Metric available for analytics queries.",
- "required": ["key", "label", "type", "description"],
+ "required": ["id", "key", "label", "type", "description"],
"properties": {
+ "id": {
+ "type": "string"
+ },
"key": {
"type": "string"
},
"label": {
"type": "string"
},
+ "description": {
+ "type": "string"
+ },
+ "unit": {
+ "type": "string",
+ "enum": ["count", "ratio", "milliseconds"]
+ },
"type": {
"type": "string",
- "enum": ["integer", "rate", "duration_ms"]
+ "enum": ["integer", "number", "rate"]
},
- "description": {
- "type": "string"
+ "aggregation": {
+ "type": "string",
+ "enum": ["sum", "average", "ratio", "derived"]
+ },
+ "filterable": {
+ "type": "boolean"
+ },
+ "sortable": {
+ "type": "boolean"
}
}
},
"DimensionDefinition": {
"type": "object",
"description": "Dimension available for analytics breakdowns and filters.",
- "required": ["key", "label", "type"],
+ "required": ["id", "key", "label", "type"],
"properties": {
+ "id": {
+ "type": "string"
+ },
"key": {
"type": "string"
},
"label": {
"type": "string"
},
- "type": {
+ "description": {
"type": "string"
},
- "description": {
+ "type": {
"type": "string"
+ },
+ "filterable": {
+ "type": "boolean"
+ },
+ "groupable": {
+ "type": "boolean"
+ },
+ "sortable": {
+ "type": "boolean"
}
}
},
@@ -6929,7 +6040,8 @@
},
"visitorTokenMode": {
"type": "string",
- "enum": ["daily"]
+ "enum": ["daily", "weekly", "monthly", "session", "none"],
+ "description": "Visitor token rotation mode. The current runtime behavior uses daily tokens; additional values are reserved for compatible future configuration."
},
"dataRetentionDays": {
"type": "integer",
@@ -7705,7 +6817,34 @@
]
},
"value": {
- "description": "Comparison value. Required unless op is exists or notExists."
+ "description": "Comparison value. Required unless op is exists or notExists.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "number"
+ },
+ {
+ "type": "boolean"
+ }
+ ]
+ }
+ }
+ ]
}
},
"additionalProperties": false
@@ -8733,7 +7872,7 @@
}
},
"parameters": {
- "siteId": {
+ "SiteIdPathParam": {
"name": "siteId",
"in": "path",
"required": true,
@@ -8743,7 +7882,7 @@
},
"description": "Site UUID."
},
- "dimension": {
+ "DimensionPathParam": {
"name": "dimension",
"in": "path",
"required": true,
@@ -8753,7 +7892,7 @@
},
"description": "Stable analytics dimension key."
},
- "eventName": {
+ "EventNamePathParam": {
"name": "eventName",
"in": "path",
"required": true,
@@ -8763,7 +7902,7 @@
},
"description": "Event name."
},
- "eventId": {
+ "EventIdPathParam": {
"name": "eventId",
"in": "path",
"required": true,
@@ -8773,27 +7912,27 @@
},
"description": "Event UUID."
},
- "visitorId": {
+ "VisitorIdPathParam": {
"name": "visitorId",
"in": "path",
"required": true,
"schema": {
"type": "string",
- "format": "uuid"
+ "maxLength": 160
},
- "description": "Visitor UUID."
+ "description": "Opaque visitor identifier."
},
- "sessionId": {
+ "SessionIdPathParam": {
"name": "sessionId",
"in": "path",
"required": true,
"schema": {
"type": "string",
- "format": "uuid"
+ "maxLength": 160
},
- "description": "Session UUID."
+ "description": "Opaque session identifier."
},
- "funnelId": {
+ "FunnelIdPathParam": {
"name": "funnelId",
"in": "path",
"required": true,
@@ -8802,6 +7941,105 @@
"format": "uuid"
},
"description": "Funnel UUID."
+ },
+ "FromQueryParam": {
+ "name": "from",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "description": "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ },
+ "ToQueryParam": {
+ "name": "to",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "description": "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ },
+ "PresetQueryParam": {
+ "name": "preset",
+ "in": "query",
+ "schema": {
+ "$ref": "#/components/schemas/Preset"
+ },
+ "description": "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time."
+ },
+ "TimeZoneQueryParam": {
+ "name": "timeZone",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "maxLength": 80,
+ "default": "UTC"
+ },
+ "description": "IANA time zone used to resolve presets. Defaults to UTC."
+ },
+ "IntervalQueryParam": {
+ "name": "interval",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "enum": ["minute", "hour", "day", "week", "month"],
+ "default": "day"
+ },
+ "description": "Time bucket granularity."
+ },
+ "MetricsQueryParam": {
+ "name": "metrics",
+ "in": "query",
+ "style": "form",
+ "explode": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "views",
+ "sessions",
+ "visitors",
+ "bounces",
+ "bounceRate",
+ "avgDurationMs",
+ "viewsPerSession",
+ "events"
+ ]
+ }
+ },
+ "description": "Comma-separated metrics to include."
+ },
+ "FilterQueryParam": {
+ "name": "filter",
+ "in": "query",
+ "style": "deepObject",
+ "explode": true,
+ "schema": {
+ "$ref": "#/components/schemas/FilterObject"
+ },
+ "description": "Simple equality filters as filter[field]=value."
+ },
+ "LimitQueryParam": {
+ "name": "limit",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 1000,
+ "default": 100
+ },
+ "description": "Maximum number of results."
+ },
+ "CursorQueryParam": {
+ "name": "cursor",
+ "in": "query",
+ "schema": {
+ "type": "string",
+ "maxLength": 512
+ },
+ "description": "Opaque pagination cursor from the previous response."
}
},
"responses": {
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index a4fb1647..089f76fc 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1,12 +1,23 @@
openapi: 3.1.0
info:
title: InsightFlare API
- description:
- Privacy-focused web analytics API. Authenticated endpoints require
- an API key passed as a Bearer token in the Authorization header. All API
- times are ISO 8601 strings and analytics ranges use [from, to) semantics. If
- from, to, and preset are omitted, analytics endpoints default to the last 7
- days ending at request time. The default timeZone is UTC.
+ description: >-
+ Privacy-focused web analytics API. Authenticated endpoints require an API
+ key passed as a Bearer token in the Authorization header. All timestamps in
+ query parameters and response objects are ISO 8601 date-time strings unless
+ the field name explicitly ends with `Ms`. Fields ending with `Ms` represent
+ millisecond values, such as durations or Unix timestamps depending on
+ context. Analytics ranges use [from, to) semantics. If from, to, and preset
+ are omitted, analytics endpoints default to the last 7 days ending at
+ request time. The default timeZone is UTC.
+
+
+ This OpenAPI document describes the behavior of the InsightFlare origin API.
+ Depending on deployment configuration, upstream infrastructure, proxies,
+ gateways, or edge providers may return additional HTTP responses before
+ requests reach the API origin, such as 429 Too Many Requests. These
+ responses are outside the standard API error envelope and are not part of
+ the stable API contract.
version: 1.0.0
contact:
name: InsightFlare
@@ -14,6 +25,11 @@ info:
license:
name: MIT
url: https://github.com/ravelloh/InsightFlare/blob/main/LICENSE
+externalDocs:
+ description: InsightFlare API documentation
+ url: https://insight.ravelloh.com/docs
+x-possible-upstream-responses:
+ - 429
servers:
- url: https://insight.ravelloh.com
description: Production
@@ -76,6 +92,7 @@ paths:
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"
+ x-required-scopes: []
/collect:
post:
operationId: collectEvent
@@ -139,6 +156,7 @@ paths:
$ref: "#/components/responses/BadRequest"
"413":
$ref: "#/components/responses/PayloadTooLarge"
+ x-required-scopes: []
/api/v1:
get:
operationId: getApiRoot
@@ -177,6 +195,7 @@ paths:
$ref: "#/components/responses/BadRequest"
"500":
$ref: "#/components/responses/InternalError"
+ x-required-scopes: []
/api/v1/token:
get:
operationId: getToken
@@ -219,6 +238,7 @@ paths:
generatedAt: 2026-06-26T12:00:00Z
"401":
$ref: "#/components/responses/Unauthorized"
+ x-required-scopes: []
/api/v1/token/check:
post:
operationId: checkToken
@@ -264,6 +284,7 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
+ x-required-scopes: []
/api/v1/capabilities:
get:
operationId: getCapabilities
@@ -313,6 +334,7 @@ paths:
generatedAt: 2026-06-26T12:00:00Z
"401":
$ref: "#/components/responses/Unauthorized"
+ x-required-scopes: []
/api/v1/team:
get:
operationId: getTeam
@@ -344,6 +366,8 @@ paths:
generatedAt: 2026-06-26T12:00:00Z
"401":
$ref: "#/components/responses/Unauthorized"
+ x-required-scopes:
+ - site:read
/api/v1/team/usage:
get:
operationId: getTeamUsage
@@ -369,6 +393,8 @@ paths:
generatedAt: 2026-06-26T12:00:00Z
"401":
$ref: "#/components/responses/Unauthorized"
+ x-required-scopes:
+ - site:read
/api/v1/team/analytics/overview:
get:
operationId: getTeamAnalyticsOverview
@@ -377,64 +403,12 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: metrics
- in: query
- style: form
- explode: false
- schema:
- type: array
- items:
- type: string
- enum:
- - views
- - sessions
- - visitors
- - bounces
- - bounceRate
- - avgDurationMs
- - viewsPerSession
- - events
- description: Comma-separated metrics to include.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/MetricsQueryParam"
responses:
"200":
description: Successful response
@@ -468,6 +442,8 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
+ x-required-scopes:
+ - analytics:read
/api/v1/team/analytics/timeseries:
get:
operationId: getTeamAnalyticsTimeseries
@@ -476,76 +452,13 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: interval
- in: query
- schema:
- type: string
- enum:
- - minute
- - hour
- - day
- - week
- - month
- default: day
- description: Time bucket granularity.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: metrics
- in: query
- style: form
- explode: false
- schema:
- type: array
- items:
- type: string
- enum:
- - views
- - sessions
- - visitors
- - bounces
- - bounceRate
- - avgDurationMs
- - viewsPerSession
- - events
- description: Comma-separated metrics to include.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/IntervalQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/MetricsQueryParam"
responses:
"200":
description: Successful response
@@ -575,6 +488,8 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
+ x-required-scopes:
+ - analytics:read
/api/v1/team/analytics/sites:
get:
operationId: getTeamAnalyticsSites
@@ -583,57 +498,11 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: metrics
- in: query
- style: form
- explode: false
- schema:
- type: array
- items:
- type: string
- enum:
- - views
- - sessions
- - visitors
- - bounces
- - bounceRate
- - avgDurationMs
- - viewsPerSession
- - events
- description: Comma-separated metrics to include.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/MetricsQueryParam"
responses:
"200":
description: Successful response
@@ -666,10 +535,12 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
+ x-required-scopes:
+ - analytics:read
/api/v1/team/analytics/breakdowns/{dimension}:
parameters:
- &a5
- $ref: "#/components/parameters/dimension"
+ $ref: "#/components/parameters/DimensionPathParam"
get:
operationId: getTeamAnalyticsBreakdown
summary: Get team analytics breakdown
@@ -677,64 +548,12 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: metrics
- in: query
- style: form
- explode: false
- schema:
- type: array
- items:
- type: string
- enum:
- - views
- - sessions
- - visitors
- - bounces
- - bounceRate
- - avgDurationMs
- - viewsPerSession
- - events
- description: Comma-separated metrics to include.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/MetricsQueryParam"
- name: limit
in: query
schema:
@@ -772,6 +591,8 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites:
get:
operationId: listSites
@@ -818,6 +639,8 @@ paths:
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
+ x-required-scopes:
+ - site:read
post:
operationId: createSite
summary: Create site
@@ -869,10 +692,12 @@ paths:
$ref: "#/components/responses/Forbidden"
"409":
$ref: "#/components/responses/Conflict"
+ x-required-scopes:
+ - site:write
/api/v1/sites/{siteId}:
parameters:
- &a3
- $ref: "#/components/parameters/siteId"
+ $ref: "#/components/parameters/SiteIdPathParam"
get:
operationId: getSite
summary: Get site
@@ -900,6 +725,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site:read
patch:
operationId: updateSite
summary: Update site
@@ -945,6 +772,8 @@ paths:
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/Conflict"
+ x-required-scopes:
+ - site:write
delete:
operationId: deleteSite
summary: Delete site
@@ -960,6 +789,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site:write
/api/v1/sites/{siteId}/tracking:
parameters:
- *a3
@@ -1002,6 +833,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site_config:read
patch:
operationId: updateTrackingSettings
summary: Update tracking settings
@@ -1061,6 +894,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site_config:write
/api/v1/sites/{siteId}/tracking/script:
parameters:
- *a3
@@ -1095,6 +930,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site_config:read
/api/v1/sites/{siteId}/privacy:
parameters:
- *a3
@@ -1130,6 +967,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site_config:read
patch:
operationId: updatePrivacySettings
summary: Update privacy settings
@@ -1176,6 +1015,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site_config:write
/api/v1/sites/{siteId}/sharing:
parameters:
- *a3
@@ -1208,6 +1049,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site_config:read
patch:
operationId: updateSharingSettings
summary: Update sharing settings
@@ -1253,6 +1096,8 @@ paths:
$ref: "#/components/responses/NotFound"
"409":
$ref: "#/components/responses/Conflict"
+ x-required-scopes:
+ - site_config:write
/api/v1/sites/{siteId}/analytics/schema:
parameters:
- *a3
@@ -1275,21 +1120,41 @@ paths:
value:
data:
metrics:
- - key: views
+ - id: views
+ key: views
label: Views
type: integer
description: Total page views.
- - key: bounceRate
+ unit: count
+ aggregation: sum
+ filterable: false
+ sortable: true
+ - id: bounceRate
+ key: bounceRate
label: Bounce rate
type: rate
description: Single-page session rate as a 0-1 ratio.
+ unit: ratio
+ aggregation: ratio
+ filterable: false
+ sortable: true
dimensions:
- - key: page.path
+ - id: page.path
+ key: page.path
label: Page path
+ description: Normalized page path from the tracked URL.
type: string
- - key: geo.country
+ filterable: true
+ groupable: true
+ sortable: true
+ - id: geo.country
+ key: geo.country
label: Country
+ description: Visitor country inferred from request metadata.
type: string
+ filterable: true
+ groupable: true
+ sortable: true
filters:
- page.path
- geo.country
@@ -1319,6 +1184,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/analytics/overview:
parameters:
- *a3
@@ -1329,64 +1196,12 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: metrics
- in: query
- style: form
- explode: false
- schema:
- type: array
- items:
- type: string
- enum:
- - views
- - sessions
- - visitors
- - bounces
- - bounceRate
- - avgDurationMs
- - viewsPerSession
- - events
- description: Comma-separated metrics to include.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/MetricsQueryParam"
responses:
"200":
description: Successful response
@@ -1411,6 +1226,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/analytics/timeseries:
parameters:
- *a3
@@ -1421,76 +1238,13 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: interval
- in: query
- schema:
- type: string
- enum:
- - minute
- - hour
- - day
- - week
- - month
- default: day
- description: Time bucket granularity.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: metrics
- in: query
- style: form
- explode: false
- schema:
- type: array
- items:
- type: string
- enum:
- - views
- - sessions
- - visitors
- - bounces
- - bounceRate
- - avgDurationMs
- - viewsPerSession
- - events
- description: Comma-separated metrics to include.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/IntervalQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/MetricsQueryParam"
responses:
"200":
description: Successful response
@@ -1522,6 +1276,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/analytics/breakdowns/{dimension}:
parameters:
- *a3
@@ -1533,64 +1289,12 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: metrics
- in: query
- style: form
- explode: false
- schema:
- type: array
- items:
- type: string
- enum:
- - views
- - sessions
- - visitors
- - bounces
- - bounceRate
- - avgDurationMs
- - viewsPerSession
- - events
- description: Comma-separated metrics to include.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/MetricsQueryParam"
- name: limit
in: query
schema:
@@ -1631,6 +1335,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/analytics/cross-breakdowns:
parameters:
- *a3
@@ -1643,46 +1349,11 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
- name: primary
in: query
schema:
@@ -1734,6 +1405,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/analytics/compare:
parameters:
- *a3
@@ -1744,46 +1417,11 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
- name: compare
in: query
schema:
@@ -1827,6 +1465,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/analytics/explore:
parameters:
- *a3
@@ -1899,6 +1539,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/analytics/retention/cohorts:
parameters:
- *a3
@@ -1909,58 +1551,12 @@ paths:
tags:
- Analytics
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: interval
- in: query
- schema:
- type: string
- enum:
- - minute
- - hour
- - day
- - week
- - month
- default: day
- description: Time bucket granularity.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/IntervalQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
responses:
"200":
description: Successful response
@@ -1995,6 +1591,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/event-types:
parameters:
- *a3
@@ -2005,39 +1603,10 @@ paths:
tags:
- Events
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
- name: limit
in: query
schema:
@@ -2079,10 +1648,12 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/event-types/{eventName}:
parameters:
- *a3
- - $ref: "#/components/parameters/eventName"
+ - $ref: "#/components/parameters/EventNamePathParam"
get:
operationId: getEventType
summary: Get event type
@@ -2090,51 +1661,11 @@ paths:
tags:
- Events
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: interval
- in: query
- schema:
- type: string
- enum:
- - minute
- - hour
- - day
- - week
- - month
- default: day
- description: Time bucket granularity.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/IntervalQueryParam"
responses:
"200":
description: Successful response
@@ -2182,6 +1713,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/events:
parameters:
- *a3
@@ -2192,60 +1725,13 @@ paths:
tags:
- Events
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: limit
- in: query
- schema:
- type: integer
- minimum: 1
- maximum: 1000
- default: 100
- description: Maximum number of results.
- - name: cursor
- in: query
- schema:
- type: string
- maxLength: 512
- description: Opaque pagination cursor from the previous response.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/LimitQueryParam"
+ - $ref: "#/components/parameters/CursorQueryParam"
- name: sort
in: query
schema:
@@ -2294,6 +1780,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/events/summary:
parameters:
- *a3
@@ -2304,46 +1792,11 @@ paths:
tags:
- Events
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
responses:
"200":
description: Successful response
@@ -2372,6 +1825,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/events/timeseries:
parameters:
- *a3
@@ -2382,58 +1837,12 @@ paths:
tags:
- Events
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: interval
- in: query
- schema:
- type: string
- enum:
- - minute
- - hour
- - day
- - week
- - month
- default: day
- description: Time bucket granularity.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/IntervalQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
- name: eventName
in: query
schema:
@@ -2471,6 +1880,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/events/search:
parameters:
- *a3
@@ -2529,10 +1940,12 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/events/{eventId}:
parameters:
- *a3
- - $ref: "#/components/parameters/eventId"
+ - $ref: "#/components/parameters/EventIdPathParam"
get:
operationId: getEvent
summary: Get event
@@ -2560,6 +1973,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/event-fields/values:
parameters:
- *a3
@@ -2570,39 +1985,10 @@ paths:
tags:
- Events
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
- name: eventName
in: query
schema:
@@ -2659,76 +2045,31 @@ paths:
timeRange: *a1
"400":
$ref: "#/components/responses/BadRequest"
- "401":
- $ref: "#/components/responses/Unauthorized"
- "403":
- $ref: "#/components/responses/Forbidden"
- "404":
- $ref: "#/components/responses/NotFound"
- /api/v1/sites/{siteId}/visitors:
- parameters:
- - *a3
- get:
- operationId: listVisitors
- summary: List visitors
- description: Lists visitors with cursor pagination.
- tags:
- - Visitors
- parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: limit
- in: query
- schema:
- type: integer
- minimum: 1
- maximum: 1000
- default: 100
- description: Maximum number of results.
- - name: cursor
- in: query
- schema:
- type: string
- maxLength: 512
- description: Opaque pagination cursor from the previous response.
+ "401":
+ $ref: "#/components/responses/Unauthorized"
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
+ /api/v1/sites/{siteId}/visitors:
+ parameters:
+ - *a3
+ get:
+ operationId: listVisitors
+ summary: List visitors
+ description: Lists visitors with cursor pagination.
+ tags:
+ - Visitors
+ parameters:
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/LimitQueryParam"
+ - $ref: "#/components/parameters/CursorQueryParam"
- name: sort
in: query
schema:
@@ -2779,11 +2120,13 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/visitors/{visitorId}:
parameters:
- *a3
- &a8
- $ref: "#/components/parameters/visitorId"
+ $ref: "#/components/parameters/VisitorIdPathParam"
get:
operationId: getVisitor
summary: Get visitor
@@ -2811,6 +2154,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/visitors/{visitorId}/sessions:
parameters:
- *a3
@@ -2822,53 +2167,12 @@ paths:
tags:
- Visitors
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: limit
- in: query
- schema:
- type: integer
- minimum: 1
- maximum: 1000
- default: 100
- description: Maximum number of results.
- - name: cursor
- in: query
- schema:
- type: string
- maxLength: 512
- description: Opaque pagination cursor from the previous response.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/LimitQueryParam"
+ - $ref: "#/components/parameters/CursorQueryParam"
responses:
"200":
description: Successful response
@@ -2906,6 +2210,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/visitors/{visitorId}/events:
parameters:
- *a3
@@ -2917,53 +2223,12 @@ paths:
tags:
- Visitors
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: limit
- in: query
- schema:
- type: integer
- minimum: 1
- maximum: 1000
- default: 100
- description: Maximum number of results.
- - name: cursor
- in: query
- schema:
- type: string
- maxLength: 512
- description: Opaque pagination cursor from the previous response.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/LimitQueryParam"
+ - $ref: "#/components/parameters/CursorQueryParam"
responses:
"200":
description: Successful response
@@ -2992,6 +2257,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/sessions:
parameters:
- *a3
@@ -3002,60 +2269,13 @@ paths:
tags:
- Sessions
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
- - name: limit
- in: query
- schema:
- type: integer
- minimum: 1
- maximum: 1000
- default: 100
- description: Maximum number of results.
- - name: cursor
- in: query
- schema:
- type: string
- maxLength: 512
- description: Opaque pagination cursor from the previous response.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
+ - $ref: "#/components/parameters/LimitQueryParam"
+ - $ref: "#/components/parameters/CursorQueryParam"
- name: sort
in: query
schema:
@@ -3096,11 +2316,13 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/sessions/{sessionId}:
parameters:
- *a3
- &a10
- $ref: "#/components/parameters/sessionId"
+ $ref: "#/components/parameters/SessionIdPathParam"
get:
operationId: getSession
summary: Get session
@@ -3128,6 +2350,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/sessions/{sessionId}/events:
parameters:
- *a3
@@ -3139,53 +2363,12 @@ paths:
tags:
- Sessions
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: limit
- in: query
- schema:
- type: integer
- minimum: 1
- maximum: 1000
- default: 100
- description: Maximum number of results.
- - name: cursor
- in: query
- schema:
- type: string
- maxLength: 512
- description: Opaque pagination cursor from the previous response.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/LimitQueryParam"
+ - $ref: "#/components/parameters/CursorQueryParam"
responses:
"200":
description: Successful response
@@ -3214,6 +2397,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/funnels:
parameters:
- *a3
@@ -3224,39 +2409,10 @@ paths:
tags:
- Funnels
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
responses:
"200":
description: Successful response
@@ -3297,6 +2453,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
post:
operationId: createFunnel
summary: Create funnel
@@ -3346,40 +2504,15 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site:write
/api/v1/sites/{siteId}/funnels/analysis:
parameters:
- *a3
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
post:
operationId: analyzeFunnel
summary: Analyze funnel
@@ -3408,7 +2541,7 @@ paths:
default:
summary: analyzeFunnel
value:
- data: &a13
+ data: &a14
steps:
- index: 0
label: Pricing
@@ -3447,10 +2580,13 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/funnels/{funnelId}:
parameters:
- *a3
- - $ref: "#/components/parameters/funnelId"
+ - &a13
+ $ref: "#/components/parameters/FunnelIdPathParam"
get:
operationId: getFunnel
summary: Get funnel
@@ -3478,6 +2614,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
patch:
operationId: updateFunnel
summary: Update funnel
@@ -3519,6 +2657,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site:write
delete:
operationId: deleteFunnel
summary: Delete funnel
@@ -3534,10 +2674,12 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - site:write
/api/v1/sites/{siteId}/funnels/{funnelId}/analysis:
parameters:
- *a3
- - $ref: "#/components/parameters/funnelId"
+ - *a13
get:
operationId: getFunnelAnalysis
summary: Get funnel analysis
@@ -3545,39 +2687,10 @@ paths:
tags:
- Funnels
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
responses:
"200":
description: Successful response
@@ -3591,7 +2704,7 @@ paths:
value:
data:
funnel: *a12
- analysis: *a13
+ analysis: *a14
meta:
requestId: req_abc123
generatedAt: 2026-06-26T12:00:00Z
@@ -3604,6 +2717,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/performance/summary:
parameters:
- *a3
@@ -3614,46 +2729,11 @@ paths:
tags:
- Performance
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
responses:
"200":
description: Successful response
@@ -3682,6 +2762,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/performance/timeseries:
parameters:
- *a3
@@ -3692,58 +2774,12 @@ paths:
tags:
- Performance
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: interval
- in: query
- schema:
- type: string
- enum:
- - minute
- - hour
- - day
- - week
- - month
- default: day
- description: Time bucket granularity.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/IntervalQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
responses:
"200":
description: Successful response
@@ -3776,6 +2812,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/performance/breakdowns/{dimension}:
parameters:
- *a3
@@ -3787,46 +2825,11 @@ paths:
tags:
- Performance
parameters:
- - name: from
- in: query
- schema:
- type: string
- format: date-time
- description:
- Inclusive ISO 8601 start time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: to
- in: query
- schema:
- type: string
- format: date-time
- description:
- Exclusive ISO 8601 end time. If from, to, and preset are omitted,
- analytics endpoints default to the last 7 days ending at request
- time.
- - name: preset
- in: query
- schema:
- $ref: "#/components/schemas/Preset"
- description:
- Named time range preset. Mutually exclusive with from and to. If
- from, to, and preset are omitted, analytics endpoints default to the
- last 7 days ending at request time.
- - name: timeZone
- in: query
- schema:
- type: string
- maxLength: 80
- default: UTC
- description: IANA time zone used to resolve presets. Defaults to UTC.
- - name: filter
- in: query
- style: deepObject
- explode: true
- schema:
- $ref: "#/components/schemas/FilterObject"
- description: Simple equality filters as filter[field]=value.
+ - $ref: "#/components/parameters/FromQueryParam"
+ - $ref: "#/components/parameters/ToQueryParam"
+ - $ref: "#/components/parameters/PresetQueryParam"
+ - $ref: "#/components/parameters/TimeZoneQueryParam"
+ - $ref: "#/components/parameters/FilterQueryParam"
- name: metric
in: query
schema:
@@ -3865,6 +2868,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/realtime/active-visitors:
parameters:
- *a3
@@ -3896,6 +2901,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/realtime/events:
parameters:
- *a3
@@ -3937,6 +2944,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/realtime/sessions:
parameters:
- *a3
@@ -3978,6 +2987,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/sites/{siteId}/realtime/snapshot:
parameters:
- *a3
@@ -4013,6 +3024,8 @@ paths:
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
+ x-required-scopes:
+ - analytics:read
/api/v1/batch:
post:
operationId: batch
@@ -4083,6 +3096,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
+ x-required-scopes:
+ - analytics:read
components:
schemas:
Meta:
@@ -4301,10 +3316,11 @@ components:
ComplexFilter:
type: object
description:
- "Advanced filter rule for explore and search endpoints. Operators:
- eq equals; neq does not equal; in is one of; notIn is not one of;
- contains includes substring; startsWith/endsWith match string edges;
- gt/gte/lt/lte compare ordered values; exists/notExists ignore value."
+ Advanced filter rule for explore and search endpoints. For eq, neq,
+ contains, startsWith, and endsWith, use a scalar value. For in and
+ notIn, use an array value. For gt, gte, lt, and lte, use a number or ISO
+ 8601 date-time string depending on the field. For exists and notExists,
+ value may be omitted.
required:
- field
- op
@@ -4332,44 +3348,83 @@ components:
- lte
- exists
- notExists
- value: {}
+ value:
+ oneOf:
+ - type: string
+ - type: number
+ - type: boolean
+ - type: array
+ items:
+ oneOf:
+ - type: string
+ - type: number
+ - type: boolean
MetricDefinition:
type: object
description: Metric available for analytics queries.
required:
+ - id
- key
- label
- type
- description
properties:
+ id:
+ type: string
key:
type: string
label:
type: string
+ description:
+ type: string
+ unit:
+ type: string
+ enum:
+ - count
+ - ratio
+ - milliseconds
type:
type: string
enum:
- integer
+ - number
- rate
- - duration_ms
- description:
+ aggregation:
type: string
+ enum:
+ - sum
+ - average
+ - ratio
+ - derived
+ filterable:
+ type: boolean
+ sortable:
+ type: boolean
DimensionDefinition:
type: object
description: Dimension available for analytics breakdowns and filters.
required:
+ - id
- key
- label
- type
properties:
+ id:
+ type: string
key:
type: string
label:
type: string
- type:
- type: string
description:
type: string
+ type:
+ type: string
+ filterable:
+ type: boolean
+ groupable:
+ type: boolean
+ sortable:
+ type: boolean
SiteAccess:
type: object
description: Sites this token may access.
@@ -4387,7 +3442,7 @@ components:
restricted means only listed siteIds.
siteIds:
type: array
- items: &a14
+ items: &a15
type: string
format: uuid
Token:
@@ -4401,7 +3456,7 @@ components:
- scopes
- siteAccess
properties:
- id: *a14
+ id: *a15
name:
type: string
maxLength: 120
@@ -4412,7 +3467,7 @@ components:
- expired
- revoked
description: active can be used; expired passed expiresAt; revoked was disabled.
- createdAt: &a15
+ createdAt: &a16
type: string
format: date-time
expiresAt:
@@ -4431,7 +3486,7 @@ components:
- id
- name
properties:
- id: *a14
+ id: *a15
name:
type: string
maxLength: 120
@@ -4473,7 +3528,7 @@ components:
scope:
type: string
maxLength: 80
- siteId: *a14
+ siteId: *a15
TokenCheckResponse:
description: Response envelope.
allOf:
@@ -4491,7 +3546,7 @@ components:
properties:
scope:
type: string
- siteId: *a14
+ siteId: *a15
allowed:
type: boolean
reason:
@@ -4618,11 +3673,11 @@ components:
Team:
type: object
properties:
- id: *a14
+ id: *a15
name:
type: string
maxLength: 120
- createdAt: *a15
+ createdAt: *a16
links:
$ref: "#/components/schemas/LinkMap"
TeamResponse:
@@ -4646,15 +3701,15 @@ components:
- sharing
- links
properties:
- id: *a14
+ id: *a15
name:
type: string
maxLength: 120
domain:
type: string
maxLength: 255
- createdAt: *a15
- updatedAt: *a15
+ createdAt: *a16
+ updatedAt: *a16
sharing:
$ref: "#/components/schemas/SharingSettings"
links:
@@ -4767,7 +3822,7 @@ components:
data:
type: object
properties:
- siteId: *a14
+ siteId: *a15
src:
type: string
format: uri
@@ -4787,6 +3842,14 @@ components:
type: string
enum:
- daily
+ - weekly
+ - monthly
+ - session
+ - none
+ description:
+ Visitor token rotation mode. The current runtime behavior uses
+ daily tokens; additional values are reserved for compatible future
+ configuration.
dataRetentionDays:
type: integer
minimum: 1
@@ -4864,7 +3927,7 @@ components:
- string
- "null"
format: date-time
- latestAvailableAt: *a15
+ latestAvailableAt: *a16
links:
$ref: "#/components/schemas/LinkMap"
OverviewMetrics:
@@ -4903,8 +3966,8 @@ components:
type: object
description: One time bucket of analytics metrics.
properties:
- start: *a15
- end: *a15
+ start: *a16
+ end: *a16
views:
type: integer
sessions:
@@ -5123,7 +4186,7 @@ components:
items:
type: object
properties:
- start: *a15
+ start: *a16
size:
type: integer
minimum: 0
@@ -5182,11 +4245,11 @@ components:
type: object
additionalProperties: true
properties:
- id: *a14
+ id: *a15
eventName:
type: string
maxLength: 120
- occurredAt: *a15
+ occurredAt: *a16
EventListResponse:
description: Response envelope for paginated list results.
allOf:
@@ -5328,6 +4391,16 @@ components:
- notExists
value:
description: Comparison value. Required unless op is exists or notExists.
+ oneOf:
+ - type: string
+ - type: number
+ - type: boolean
+ - type: array
+ items:
+ oneOf:
+ - type: string
+ - type: number
+ - type: boolean
additionalProperties: false
EventSearchRequest:
type: object
@@ -5364,8 +4437,8 @@ components:
visitorId:
type: string
maxLength: 160
- firstSeenAt: *a15
- lastSeenAt: *a15
+ firstSeenAt: *a16
+ lastSeenAt: *a16
views:
type: integer
minimum: 0
@@ -5408,7 +4481,7 @@ components:
visitorId:
type: string
maxLength: 160
- startedAt: *a15
+ startedAt: *a16
endedAt:
type:
- string
@@ -5476,8 +4549,8 @@ components:
description: Performance metric point.
additionalProperties: true
properties:
- start: *a15
- end: *a15
+ start: *a16
+ end: *a16
ttfb:
type: number
description: Time to first byte in milliseconds.
@@ -5648,8 +4721,8 @@ components:
- createdAt
- updatedAt
properties:
- id: *a14
- siteId: *a14
+ id: *a15
+ siteId: *a15
name:
type: string
maxLength: 200
@@ -5662,8 +4735,8 @@ components:
type: array
items:
$ref: "#/components/schemas/FunnelStep"
- createdAt: *a15
- updatedAt: *a15
+ createdAt: *a16
+ updatedAt: *a16
links:
$ref: "#/components/schemas/LinkMap"
FunnelResponse:
@@ -5886,7 +4959,7 @@ components:
type: string
enum:
- healthy
- timestamp: *a15
+ timestamp: *a16
CollectPage:
type: object
description: Page context for a collect payload.
@@ -6057,7 +5130,7 @@ components:
scheme: bearer
description: API key passed as a Bearer token in the Authorization header.
parameters:
- siteId:
+ SiteIdPathParam:
name: siteId
in: path
required: true
@@ -6065,7 +5138,7 @@ components:
type: string
format: uuid
description: Site UUID.
- dimension:
+ DimensionPathParam:
name: dimension
in: path
required: true
@@ -6073,7 +5146,7 @@ components:
type: string
maxLength: 120
description: Stable analytics dimension key.
- eventName:
+ EventNamePathParam:
name: eventName
in: path
required: true
@@ -6081,7 +5154,7 @@ components:
type: string
maxLength: 120
description: Event name.
- eventId:
+ EventIdPathParam:
name: eventId
in: path
required: true
@@ -6089,23 +5162,23 @@ components:
type: string
format: uuid
description: Event UUID.
- visitorId:
+ VisitorIdPathParam:
name: visitorId
in: path
required: true
schema:
type: string
- format: uuid
- description: Visitor UUID.
- sessionId:
+ maxLength: 160
+ description: Opaque visitor identifier.
+ SessionIdPathParam:
name: sessionId
in: path
required: true
schema:
type: string
- format: uuid
- description: Session UUID.
- funnelId:
+ maxLength: 160
+ description: Opaque session identifier.
+ FunnelIdPathParam:
name: funnelId
in: path
required: true
@@ -6113,28 +5186,119 @@ components:
type: string
format: uuid
description: Funnel UUID.
+ FromQueryParam:
+ name: from
+ in: query
+ schema:
+ type: string
+ format: date-time
+ description:
+ Inclusive ISO 8601 start time. If from, to, and preset are omitted,
+ analytics endpoints default to the last 7 days ending at request time.
+ ToQueryParam:
+ name: to
+ in: query
+ schema:
+ type: string
+ format: date-time
+ description:
+ Exclusive ISO 8601 end time. If from, to, and preset are omitted,
+ analytics endpoints default to the last 7 days ending at request time.
+ PresetQueryParam:
+ name: preset
+ in: query
+ schema:
+ $ref: "#/components/schemas/Preset"
+ description:
+ Named time range preset. Mutually exclusive with from and to. If
+ from, to, and preset are omitted, analytics endpoints default to the
+ last 7 days ending at request time.
+ TimeZoneQueryParam:
+ name: timeZone
+ in: query
+ schema:
+ type: string
+ maxLength: 80
+ default: UTC
+ description: IANA time zone used to resolve presets. Defaults to UTC.
+ IntervalQueryParam:
+ name: interval
+ in: query
+ schema:
+ type: string
+ enum:
+ - minute
+ - hour
+ - day
+ - week
+ - month
+ default: day
+ description: Time bucket granularity.
+ MetricsQueryParam:
+ name: metrics
+ in: query
+ style: form
+ explode: false
+ schema:
+ type: array
+ items:
+ type: string
+ enum:
+ - views
+ - sessions
+ - visitors
+ - bounces
+ - bounceRate
+ - avgDurationMs
+ - viewsPerSession
+ - events
+ description: Comma-separated metrics to include.
+ FilterQueryParam:
+ name: filter
+ in: query
+ style: deepObject
+ explode: true
+ schema:
+ $ref: "#/components/schemas/FilterObject"
+ description: Simple equality filters as filter[field]=value.
+ LimitQueryParam:
+ name: limit
+ in: query
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 1000
+ default: 100
+ description: Maximum number of results.
+ CursorQueryParam:
+ name: cursor
+ in: query
+ schema:
+ type: string
+ maxLength: 512
+ description: Opaque pagination cursor from the previous response.
responses:
BadRequest:
description: Bad request
- content: &a16
+ content: &a17
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
Unauthorized:
description: Authentication failed
- content: *a16
+ content: *a17
Forbidden:
description: Insufficient permissions
- content: *a16
+ content: *a17
NotFound:
description: Resource not found
- content: *a16
+ content: *a17
Conflict:
description: Conflict
- content: *a16
+ content: *a17
PayloadTooLarge:
description: Payload too large
- content: *a16
+ content: *a17
InternalError:
description: Internal error
- content: *a16
+ content: *a17
diff --git a/scripts/check-openapi-contract.mjs b/scripts/check-openapi-contract.mjs
index 981ad431..be7f0d0b 100644
--- a/scripts/check-openapi-contract.mjs
+++ b/scripts/check-openapi-contract.mjs
@@ -61,6 +61,10 @@ function refName(value) {
return String(value.$ref).split("/").at(-1) ?? null;
}
+function dereferenceParameter(parameter) {
+ return dereference(parameter);
+}
+
function responseSchemas(operation) {
const schemas = [];
for (const response of Object.values(operation.responses ?? {})) {
@@ -163,7 +167,7 @@ for (const [path, pathItem] of Object.entries(openapi.paths ?? {})) {
...(pathItem.parameters ?? []),
...(operation.parameters ?? []),
]
- .map(dereference)
+ .map(dereferenceParameter)
.filter(Boolean);
for (const parameter of parameters) {
if (parameter.name === "queryName") {
@@ -219,6 +223,28 @@ for (const [path, pathItem] of Object.entries(openapi.paths ?? {})) {
`${key} /api/v1 success response must not use GenericObjectResponse`,
);
}
+
+ if (operation.responses?.["429"]) {
+ issues.push(`${key} must not declare 429 as a stable origin response`);
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(operation, "x-required-scopes")) {
+ issues.push(`${key} is missing x-required-scopes`);
+ } else if (!Array.isArray(operation["x-required-scopes"])) {
+ issues.push(`${key} x-required-scopes must be an array`);
+ } else if (
+ !(operation.security && operation.security.length === 0) &&
+ operation["x-required-scopes"].length === 0 &&
+ path.startsWith("/api/v1") &&
+ ![
+ "/api/v1",
+ "/api/v1/token",
+ "/api/v1/token/check",
+ "/api/v1/capabilities",
+ ].includes(path)
+ ) {
+ issues.push(`${key} authenticated operation should declare a scope`);
+ }
}
}
@@ -259,6 +285,69 @@ for (const forbidden of [
}
}
+if (!openapi.info?.description?.includes("ISO 8601 date-time strings")) {
+ issues.push("Top-level description must describe ISO 8601 timestamps");
+}
+if (
+ !openapi.info?.description?.includes(
+ "outside the standard API error envelope",
+ )
+) {
+ issues.push(
+ "Top-level description must explain upstream 429 as non-contract",
+ );
+}
+if (!Array.isArray(openapi["x-possible-upstream-responses"])) {
+ issues.push("OpenAPI must expose x-possible-upstream-responses");
+}
+
+for (const [name, parameter] of Object.entries(
+ openapi.components?.parameters ?? {},
+)) {
+ if (["FromQueryParam", "ToQueryParam"].includes(name)) {
+ if (
+ parameter.schema?.type !== "string" ||
+ parameter.schema?.format !== "date-time"
+ ) {
+ issues.push(`${name} must be an ISO 8601 date-time string parameter`);
+ }
+ if (/unix|millisecond/i.test(parameter.description ?? "")) {
+ issues.push(`${name} description must not mention Unix milliseconds`);
+ }
+ }
+}
+
+for (const name of [
+ "SiteIdPathParam",
+ "FromQueryParam",
+ "ToQueryParam",
+ "PresetQueryParam",
+ "TimeZoneQueryParam",
+ "MetricsQueryParam",
+ "FilterQueryParam",
+ "LimitQueryParam",
+ "CursorQueryParam",
+]) {
+ if (!openapi.components?.parameters?.[name]) {
+ issues.push(`Missing reusable parameter ${name}`);
+ }
+}
+
+const visitorParam = openapi.components?.parameters?.VisitorIdPathParam;
+if (visitorParam?.schema?.format === "uuid") {
+ issues.push("VisitorIdPathParam must not require uuid format");
+}
+const sessionParam = openapi.components?.parameters?.SessionIdPathParam;
+if (sessionParam?.schema?.format === "uuid") {
+ issues.push("SessionIdPathParam must not require uuid format");
+}
+
+const complexFilterValue =
+ openapi.components?.schemas?.ComplexFilter?.properties?.value;
+if (!Array.isArray(complexFilterValue?.oneOf)) {
+ issues.push("ComplexFilter.value must define a constrained oneOf schema");
+}
+
const collect = openapi.paths?.["/collect"]?.post;
if (collect?.responses?.["429"]) {
issues.push("/collect must not declare a 429 response");
diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts
index 0759cc10..67a6cece 100644
--- a/scripts/generate-openapi.ts
+++ b/scripts/generate-openapi.ts
@@ -16,11 +16,14 @@ interface Operation {
parameters?: unknown[];
requestBody?: unknown;
responses: Record;
+ "x-required-scopes"?: string[];
}
interface OpenAPISpec {
openapi: string;
info: Record;
+ externalDocs?: Record;
+ "x-possible-upstream-responses"?: number[];
servers: Array<{ url: string; description: string }>;
security: Array>;
tags: Array<{ name: string; description: string }>;
@@ -42,6 +45,10 @@ function ref(name: string) {
return { $ref: `#/components/schemas/${name}` };
}
+function parameterRef(name: string) {
+ return { $ref: `#/components/parameters/${name}` };
+}
+
function response(description: string, schema: string, example?: unknown) {
return {
description,
@@ -172,8 +179,35 @@ function errorResponses(...codes: string[]) {
return map;
}
+function requiredScopesForOperation(input: Operation): string[] {
+ if (input.security && input.security.length === 0) return [];
+ if (input["x-required-scopes"]) return input["x-required-scopes"];
+
+ const [tag] = input.tags;
+ const isWrite = /^(create|update|delete)/i.test(input.operationId);
+
+ if (tag === "Analytics" || tag === "Events" || tag === "Visitors") {
+ return ["analytics:read"];
+ }
+ if (tag === "Sessions" || tag === "Performance" || tag === "Realtime") {
+ return ["analytics:read"];
+ }
+ if (tag === "Batch") return ["analytics:read"];
+ if (tag === "Sites") return isWrite ? ["site:write"] : ["site:read"];
+ if (tag === "Settings") {
+ return isWrite ? ["site_config:write"] : ["site_config:read"];
+ }
+ if (tag === "Funnels") return isWrite ? ["site:write"] : ["analytics:read"];
+ if (tag === "Team") return ["site:read"];
+
+ return [];
+}
+
function op(input: Operation): Operation {
- return input;
+ return {
+ ...input,
+ "x-required-scopes": requiredScopesForOperation(input),
+ };
}
function queryParam(name: string, schema: unknown, description: string) {
@@ -181,95 +215,25 @@ function queryParam(name: string, schema: unknown, description: string) {
}
function timeParams(includeInterval = false) {
- const defaultHint =
- " If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time.";
return [
- queryParam(
- "from",
- { type: "string", format: "date-time" },
- `Inclusive ISO 8601 start time.${defaultHint}`,
- ),
- queryParam(
- "to",
- { type: "string", format: "date-time" },
- `Exclusive ISO 8601 end time.${defaultHint}`,
- ),
- queryParam(
- "preset",
- ref("Preset"),
- `Named time range preset. Mutually exclusive with from and to.${defaultHint}`,
- ),
- queryParam(
- "timeZone",
- { type: "string", maxLength: 80, default: "UTC" },
- "IANA time zone used to resolve presets. Defaults to UTC.",
- ),
- ...(includeInterval
- ? [
- queryParam(
- "interval",
- {
- type: "string",
- enum: ["minute", "hour", "day", "week", "month"],
- default: "day",
- },
- "Time bucket granularity.",
- ),
- ]
- : []),
+ parameterRef("FromQueryParam"),
+ parameterRef("ToQueryParam"),
+ parameterRef("PresetQueryParam"),
+ parameterRef("TimeZoneQueryParam"),
+ ...(includeInterval ? [parameterRef("IntervalQueryParam")] : []),
];
}
function filterParam() {
- return {
- name: "filter",
- in: "query",
- style: "deepObject",
- explode: true,
- schema: ref("FilterObject"),
- description: "Simple equality filters as filter[field]=value.",
- };
+ return parameterRef("FilterQueryParam");
}
function metricParam() {
- return {
- name: "metrics",
- in: "query",
- style: "form",
- explode: false,
- schema: {
- type: "array",
- items: {
- type: "string",
- enum: [
- "views",
- "sessions",
- "visitors",
- "bounces",
- "bounceRate",
- "avgDurationMs",
- "viewsPerSession",
- "events",
- ],
- },
- },
- description: "Comma-separated metrics to include.",
- };
+ return parameterRef("MetricsQueryParam");
}
function cursorParams() {
- return [
- queryParam(
- "limit",
- { type: "integer", minimum: 1, maximum: 1000, default: 100 },
- "Maximum number of results.",
- ),
- queryParam(
- "cursor",
- { type: "string", maxLength: 512 },
- "Opaque pagination cursor from the previous response.",
- ),
- ];
+ return [parameterRef("LimitQueryParam"), parameterRef("CursorQueryParam")];
}
function sortParam() {
@@ -625,7 +589,7 @@ function buildSchemas(): Record {
ComplexFilter: {
type: "object",
description:
- "Advanced filter rule for explore and search endpoints. Operators: eq equals; neq does not equal; in is one of; notIn is not one of; contains includes substring; startsWith/endsWith match string edges; gt/gte/lt/lte compare ordered values; exists/notExists ignore value.",
+ "Advanced filter rule for explore and search endpoints. For eq, neq, contains, startsWith, and endsWith, use a scalar value. For in and notIn, use an array value. For gt, gte, lt, and lte, use a number or ISO 8601 date-time string depending on the field. For exists and notExists, value may be omitted.",
required: ["field", "op"],
properties: {
field: {
@@ -653,29 +617,57 @@ function buildSchemas(): Record {
"notExists",
],
},
- value: {},
+ value: {
+ oneOf: [
+ { type: "string" },
+ { type: "number" },
+ { type: "boolean" },
+ {
+ type: "array",
+ items: {
+ oneOf: [
+ { type: "string" },
+ { type: "number" },
+ { type: "boolean" },
+ ],
+ },
+ },
+ ],
+ },
},
},
MetricDefinition: {
type: "object",
description: "Metric available for analytics queries.",
- required: ["key", "label", "type", "description"],
+ required: ["id", "key", "label", "type", "description"],
properties: {
+ id: { type: "string" },
key: { type: "string" },
label: { type: "string" },
- type: { type: "string", enum: ["integer", "rate", "duration_ms"] },
description: { type: "string" },
+ unit: { type: "string", enum: ["count", "ratio", "milliseconds"] },
+ type: { type: "string", enum: ["integer", "number", "rate"] },
+ aggregation: {
+ type: "string",
+ enum: ["sum", "average", "ratio", "derived"],
+ },
+ filterable: { type: "boolean" },
+ sortable: { type: "boolean" },
},
},
DimensionDefinition: {
type: "object",
description: "Dimension available for analytics breakdowns and filters.",
- required: ["key", "label", "type"],
+ required: ["id", "key", "label", "type"],
properties: {
+ id: { type: "string" },
key: { type: "string" },
label: { type: "string" },
- type: { type: "string" },
description: { type: "string" },
+ type: { type: "string" },
+ filterable: { type: "boolean" },
+ groupable: { type: "boolean" },
+ sortable: { type: "boolean" },
},
},
SiteAccess: {
@@ -942,7 +934,12 @@ function buildSchemas(): Record {
respectDoNotTrack: { type: "boolean" },
anonymizeIp: { type: "boolean" },
euMode: { type: "boolean" },
- visitorTokenMode: { type: "string", enum: ["daily"] },
+ visitorTokenMode: {
+ type: "string",
+ enum: ["daily", "weekly", "monthly", "session", "none"],
+ description:
+ "Visitor token rotation mode. The current runtime behavior uses daily tokens; additional values are reserved for compatible future configuration.",
+ },
dataRetentionDays: { type: "integer", minimum: 1 },
},
},
@@ -1346,6 +1343,21 @@ function buildSchemas(): Record {
value: {
description:
"Comparison value. Required unless op is exists or notExists.",
+ oneOf: [
+ { type: "string" },
+ { type: "number" },
+ { type: "boolean" },
+ {
+ type: "array",
+ items: {
+ oneOf: [
+ { type: "string" },
+ { type: "number" },
+ { type: "boolean" },
+ ],
+ },
+ },
+ ],
},
},
additionalProperties: false,
@@ -1849,12 +1861,13 @@ function buildSchemas(): Record {
}
function buildPaths(): OpenAPISpec["paths"] {
- const siteParam = { $ref: "#/components/parameters/siteId" };
- const dimensionParam = { $ref: "#/components/parameters/dimension" };
- const eventNameParam = { $ref: "#/components/parameters/eventName" };
- const eventIdParam = { $ref: "#/components/parameters/eventId" };
- const visitorIdParam = { $ref: "#/components/parameters/visitorId" };
- const sessionIdParam = { $ref: "#/components/parameters/sessionId" };
+ const siteParam = parameterRef("SiteIdPathParam");
+ const dimensionParam = parameterRef("DimensionPathParam");
+ const eventNameParam = parameterRef("EventNamePathParam");
+ const eventIdParam = parameterRef("EventIdPathParam");
+ const visitorIdParam = parameterRef("VisitorIdPathParam");
+ const sessionIdParam = parameterRef("SessionIdPathParam");
+ const funnelIdParam = parameterRef("FunnelIdPathParam");
return {
"/healthz": {
@@ -2646,7 +2659,7 @@ function buildPaths(): OpenAPISpec["paths"] {
}),
},
"/api/v1/sites/{siteId}/funnels/{funnelId}": {
- parameters: [siteParam, { $ref: "#/components/parameters/funnelId" }],
+ parameters: [siteParam, funnelIdParam],
get: op({
operationId: "getFunnel",
summary: "Get funnel",
@@ -2680,7 +2693,7 @@ function buildPaths(): OpenAPISpec["paths"] {
}),
},
"/api/v1/sites/{siteId}/funnels/{funnelId}/analysis": {
- parameters: [siteParam, { $ref: "#/components/parameters/funnelId" }],
+ parameters: [siteParam, funnelIdParam],
get: op({
operationId: "getFunnelAnalysis",
summary: "Get funnel analysis",
@@ -3003,21 +3016,49 @@ function responseExampleFor(schemaName: string | null, operationId: string) {
AnalyticsSchemaResponse: success({
metrics: [
{
+ id: "views",
key: "views",
label: "Views",
type: "integer",
description: "Total page views.",
+ unit: "count",
+ aggregation: "sum",
+ filterable: false,
+ sortable: true,
},
{
+ id: "bounceRate",
key: "bounceRate",
label: "Bounce rate",
type: "rate",
description: "Single-page session rate as a 0-1 ratio.",
+ unit: "ratio",
+ aggregation: "ratio",
+ filterable: false,
+ sortable: true,
},
],
dimensions: [
- { key: "page.path", label: "Page path", type: "string" },
- { key: "geo.country", label: "Country", type: "string" },
+ {
+ id: "page.path",
+ key: "page.path",
+ label: "Page path",
+ description: "Normalized page path from the tracked URL.",
+ type: "string",
+ filterable: true,
+ groupable: true,
+ sortable: true,
+ },
+ {
+ id: "geo.country",
+ key: "geo.country",
+ label: "Country",
+ description: "Visitor country inferred from request metadata.",
+ type: "string",
+ filterable: true,
+ groupable: true,
+ sortable: true,
+ },
],
filters: ["page.path", "geo.country"],
operators: ["eq", "in", "startsWith"],
@@ -3384,7 +3425,7 @@ function buildSpec(): OpenAPISpec {
info: {
title: "InsightFlare API",
description:
- "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All API times are ISO 8601 strings and analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.",
+ "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with `Ms`. Fields ending with `Ms` represent millisecond values, such as durations or Unix timestamps depending on context. Analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.\n\nThis OpenAPI document describes the behavior of the InsightFlare origin API. Depending on deployment configuration, upstream infrastructure, proxies, gateways, or edge providers may return additional HTTP responses before requests reach the API origin, such as 429 Too Many Requests. These responses are outside the standard API error envelope and are not part of the stable API contract.",
version: "1.0.0",
contact: {
name: "InsightFlare",
@@ -3395,6 +3436,11 @@ function buildSpec(): OpenAPISpec {
url: "https://github.com/ravelloh/InsightFlare/blob/main/LICENSE",
},
},
+ externalDocs: {
+ description: "InsightFlare API documentation",
+ url: "https://insight.ravelloh.com/docs",
+ },
+ "x-possible-upstream-responses": [429],
servers: [
{ url: "https://insight.ravelloh.com", description: "Production" },
],
@@ -3431,55 +3477,136 @@ function buildSpec(): OpenAPISpec {
},
},
parameters: {
- siteId: {
+ SiteIdPathParam: {
name: "siteId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Site UUID.",
},
- dimension: {
+ DimensionPathParam: {
name: "dimension",
in: "path",
required: true,
schema: { type: "string", maxLength: 120 },
description: "Stable analytics dimension key.",
},
- eventName: {
+ EventNamePathParam: {
name: "eventName",
in: "path",
required: true,
schema: { type: "string", maxLength: 120 },
description: "Event name.",
},
- eventId: {
+ EventIdPathParam: {
name: "eventId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Event UUID.",
},
- visitorId: {
+ VisitorIdPathParam: {
name: "visitorId",
in: "path",
required: true,
- schema: { type: "string", format: "uuid" },
- description: "Visitor UUID.",
+ schema: { type: "string", maxLength: 160 },
+ description: "Opaque visitor identifier.",
},
- sessionId: {
+ SessionIdPathParam: {
name: "sessionId",
in: "path",
required: true,
- schema: { type: "string", format: "uuid" },
- description: "Session UUID.",
+ schema: { type: "string", maxLength: 160 },
+ description: "Opaque session identifier.",
},
- funnelId: {
+ FunnelIdPathParam: {
name: "funnelId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Funnel UUID.",
},
+ FromQueryParam: {
+ name: "from",
+ in: "query",
+ schema: { type: "string", format: "date-time" },
+ description:
+ "Inclusive ISO 8601 start time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time.",
+ },
+ ToQueryParam: {
+ name: "to",
+ in: "query",
+ schema: { type: "string", format: "date-time" },
+ description:
+ "Exclusive ISO 8601 end time. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time.",
+ },
+ PresetQueryParam: {
+ name: "preset",
+ in: "query",
+ schema: ref("Preset"),
+ description:
+ "Named time range preset. Mutually exclusive with from and to. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time.",
+ },
+ TimeZoneQueryParam: {
+ name: "timeZone",
+ in: "query",
+ schema: { type: "string", maxLength: 80, default: "UTC" },
+ description:
+ "IANA time zone used to resolve presets. Defaults to UTC.",
+ },
+ IntervalQueryParam: {
+ name: "interval",
+ in: "query",
+ schema: {
+ type: "string",
+ enum: ["minute", "hour", "day", "week", "month"],
+ default: "day",
+ },
+ description: "Time bucket granularity.",
+ },
+ MetricsQueryParam: {
+ name: "metrics",
+ in: "query",
+ style: "form",
+ explode: false,
+ schema: {
+ type: "array",
+ items: {
+ type: "string",
+ enum: [
+ "views",
+ "sessions",
+ "visitors",
+ "bounces",
+ "bounceRate",
+ "avgDurationMs",
+ "viewsPerSession",
+ "events",
+ ],
+ },
+ },
+ description: "Comma-separated metrics to include.",
+ },
+ FilterQueryParam: {
+ name: "filter",
+ in: "query",
+ style: "deepObject",
+ explode: true,
+ schema: ref("FilterObject"),
+ description: "Simple equality filters as filter[field]=value.",
+ },
+ LimitQueryParam: {
+ name: "limit",
+ in: "query",
+ schema: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
+ description: "Maximum number of results.",
+ },
+ CursorQueryParam: {
+ name: "cursor",
+ in: "query",
+ schema: { type: "string", maxLength: 512 },
+ description: "Opaque pagination cursor from the previous response.",
+ },
},
responses: {
BadRequest: { description: "Bad request", ...errorContent },
diff --git a/scripts/skills-template.json b/scripts/skills-template.json
index 436f6b4e..fdc97a59 100644
--- a/scripts/skills-template.json
+++ b/scripts/skills-template.json
@@ -24,10 +24,11 @@
}
},
"common_query_parameters": {
- "description": "These parameters are available on all analytics query endpoints (queryName-based). They are not repeated per-endpoint for brevity.",
+ "description": "These parameters are available on analytics query endpoints. The OpenAPI document remains the source of truth for endpoint-specific parameters.",
"time_window": {
- "from": "Start timestamp in Unix milliseconds (required).",
- "to": "End timestamp in Unix milliseconds (required).",
+ "from": "Inclusive ISO 8601 date-time string. Optional when preset is used.",
+ "to": "Exclusive ISO 8601 date-time string. Optional when preset is used.",
+ "preset": "Named time range preset such as last_7_days or last_30_days. Mutually exclusive with from/to.",
"timeZone": "IANA timezone identifier (e.g. America/New_York). Defaults to UTC.",
"tz": "Alias for timeZone."
},
@@ -64,17 +65,17 @@
"typical_workflow": [
"1. Obtain an API key from the user (ask them, or direct them to dashboard → Settings → API Keys).",
"2. Call GET /api/v1/sites to list available sites and get siteId values.",
- "3. Use the siteId to query analytics: GET /api/v1/sites/{siteId}/analytics/overview?from=...&to=...",
- "4. For time-series data, use the /trend endpoint with an interval parameter.",
- "5. For multiple queries at once, use POST /api/v1/sites/{siteId}/analytics/batch.",
- "6. For team-level summary, use GET /api/v1/team/dashboard."
+ "3. Use the siteId to query analytics: GET /api/v1/sites/{siteId}/analytics/overview?preset=last_7_days&timeZone=UTC.",
+ "4. For time-series data, use GET /api/v1/sites/{siteId}/analytics/timeseries with an interval parameter.",
+ "5. For multiple queries at once, use POST /api/v1/batch.",
+ "6. For team-level summary, use GET /api/v1/team/analytics/overview."
],
"implementation_notes": [
- "All timestamps are in Unix milliseconds (not seconds).",
+ "All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with Ms.",
"The siteId parameter is a UUID. Obtain it from GET /api/v1/sites.",
- "Pagination uses page/pageSize for visitors and sessions endpoints.",
- "The /analytics/{queryName} path supports all query names listed in the endpoints above.",
- "Rate limits are not currently enforced but may be added. Design for graceful 429 handling.",
+ "Pagination uses cursor/limit for visitors, sessions, and events endpoints.",
+ "Use the OpenAPI paths directly; analytics endpoints are explicit resources such as /analytics/overview, /analytics/timeseries, and /analytics/breakdowns/{dimension}.",
+ "Depending on deployment configuration, upstream infrastructure may return additional responses such as 429 before requests reach the API origin. Those responses are outside the stable API error envelope.",
"For LLM agents: always ask the user for an API key before making any request. Do not attempt to guess or generate keys."
]
}
From c98d585e0c0605b666bf291a482eae0c6361b22c Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 17:59:15 +0800
Subject: [PATCH 05/40] chore: add explicit prettier defaults and read API
version from package.json
- Add explicit prettier configuration rules for better documentation
- Update generate-openapi.ts and generate-skills.ts to read version from package.json
- Regenerate OpenAPI and Skills specs with correct version (0.1.0)
---
docs/openapi.json | 6 +++---
docs/openapi.yaml | 6 +++---
docs/skills.json | 2 +-
prettier.config.js | 7 +++++++
scripts/generate-openapi.ts | 15 +++++++++++----
scripts/generate-skills.ts | 9 +++++++--
6 files changed, 32 insertions(+), 13 deletions(-)
diff --git a/docs/openapi.json b/docs/openapi.json
index b2855f09..5eb1f855 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "InsightFlare API",
"description": "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with `Ms`. Fields ending with `Ms` represent millisecond values, such as durations or Unix timestamps depending on context. Analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.\n\nThis OpenAPI document describes the behavior of the InsightFlare origin API. Depending on deployment configuration, upstream infrastructure, proxies, gateways, or edge providers may return additional HTTP responses before requests reach the API origin, such as 429 Too Many Requests. These responses are outside the standard API error envelope and are not part of the stable API contract.",
- "version": "1.0.0",
+ "version": "0.1.0",
"contact": {
"name": "InsightFlare",
"url": "https://github.com/ravelloh/InsightFlare"
@@ -232,7 +232,7 @@
"summary": "getApiRoot",
"value": {
"data": {
- "version": "1.0.0",
+ "version": "0.1.0",
"service": "InsightFlare Analytics API",
"links": {
"self": "/api/v1",
@@ -408,7 +408,7 @@
"summary": "getCapabilities",
"value": {
"data": {
- "apiVersion": "1.0.0",
+ "apiVersion": "0.1.0",
"features": {
"sites": true,
"tracking": true,
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 089f76fc..33ba8f72 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -18,7 +18,7 @@ info:
requests reach the API origin, such as 429 Too Many Requests. These
responses are outside the standard API error envelope and are not part of
the stable API contract.
- version: 1.0.0
+ version: 0.1.0
contact:
name: InsightFlare
url: https://github.com/ravelloh/InsightFlare
@@ -179,7 +179,7 @@ paths:
summary: getApiRoot
value:
data:
- version: 1.0.0
+ version: 0.1.0
service: InsightFlare Analytics API
links:
self: /api/v1
@@ -304,7 +304,7 @@ paths:
summary: getCapabilities
value:
data:
- apiVersion: 1.0.0
+ apiVersion: 0.1.0
features:
sites: true
tracking: true
diff --git a/docs/skills.json b/docs/skills.json
index b6df01ab..0624d89e 100644
--- a/docs/skills.json
+++ b/docs/skills.json
@@ -1,6 +1,6 @@
{
"api": "InsightFlare Analytics API",
- "version": "1.0.0",
+ "version": "0.1.0",
"description": "Privacy-focused web analytics platform.",
"baseUrl": "https://insight.ravelloh.com",
"openapiUrl": "/.well-known/openapi.json",
diff --git a/prettier.config.js b/prettier.config.js
index d63847af..a2a593e8 100644
--- a/prettier.config.js
+++ b/prettier.config.js
@@ -1,6 +1,13 @@
/** @type {import("prettier").Config} */
const config = {
endOfLine: "auto",
+ semi: true,
+ singleQuote: false,
+ trailingComma: "all",
+ tabWidth: 2,
+ printWidth: 80,
+ bracketSpacing: true,
+ arrowParens: "always",
};
export default config;
diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts
index 67a6cece..ad41f4df 100644
--- a/scripts/generate-openapi.ts
+++ b/scripts/generate-openapi.ts
@@ -1,10 +1,17 @@
#!/usr/bin/env tsx
import { execSync } from "child_process";
-import { writeFileSync } from "fs";
+import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
import YAML from "yaml";
+const ROOT = resolve(import.meta.dirname, "..");
+
+function getAppVersion(): string {
+ const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
+ return pkg.version;
+}
+
type HttpMethod = "get" | "post" | "patch" | "delete";
interface Operation {
@@ -2914,7 +2921,7 @@ function responseExampleFor(schemaName: string | null, operationId: string) {
const examples: Record = {
HealthResponse: { status: "healthy", timestamp: sampleGeneratedAt },
RootDiscoveryResponse: success({
- version: "1.0.0",
+ version: getAppVersion(),
service: "InsightFlare Analytics API",
links: {
self: "/api/v1",
@@ -2942,7 +2949,7 @@ function responseExampleFor(schemaName: string | null, operationId: string) {
],
}),
CapabilitiesResponse: success({
- apiVersion: "1.0.0",
+ apiVersion: getAppVersion(),
features: {
sites: true,
tracking: true,
@@ -3426,7 +3433,7 @@ function buildSpec(): OpenAPISpec {
title: "InsightFlare API",
description:
"Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with `Ms`. Fields ending with `Ms` represent millisecond values, such as durations or Unix timestamps depending on context. Analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.\n\nThis OpenAPI document describes the behavior of the InsightFlare origin API. Depending on deployment configuration, upstream infrastructure, proxies, gateways, or edge providers may return additional HTTP responses before requests reach the API origin, such as 429 Too Many Requests. These responses are outside the standard API error envelope and are not part of the stable API contract.",
- version: "1.0.0",
+ version: getAppVersion(),
contact: {
name: "InsightFlare",
url: "https://github.com/ravelloh/InsightFlare",
diff --git a/scripts/generate-skills.ts b/scripts/generate-skills.ts
index 39115065..618cad0d 100644
--- a/scripts/generate-skills.ts
+++ b/scripts/generate-skills.ts
@@ -1,16 +1,21 @@
#!/usr/bin/env tsx
import { execSync } from "child_process";
-import { writeFileSync } from "fs";
+import { readFileSync, writeFileSync } from "fs";
import { resolve } from "path";
const ROOT = resolve(import.meta.dirname, "..");
const OUTPUT_PATH = resolve(ROOT, "docs/skills.json");
+function getAppVersion(): string {
+ const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8"));
+ return pkg.version;
+}
+
function generate() {
const manifest = {
api: "InsightFlare Analytics API",
- version: "1.0.0",
+ version: getAppVersion(),
description: "Privacy-focused web analytics platform.",
baseUrl: "https://insight.ravelloh.com",
openapiUrl: "/.well-known/openapi.json",
From a48bf7f68396982f7a5ed316aad4f22c23f31cee Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:05:44 +0800
Subject: [PATCH 06/40] test: fix the test timeout issue
---
src/lib/dashboard/__tests__/client-request.test.ts | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/src/lib/dashboard/__tests__/client-request.test.ts b/src/lib/dashboard/__tests__/client-request.test.ts
index 9f0c3367..e089de45 100644
--- a/src/lib/dashboard/__tests__/client-request.test.ts
+++ b/src/lib/dashboard/__tests__/client-request.test.ts
@@ -4,6 +4,11 @@ import {
fetchPrivateJson,
fetchPrivateJsonMutate,
} from "@/lib/dashboard/client-request";
+import { handleDemoRequest } from "@/lib/realtime/mock";
+
+vi.mock("@/lib/realtime/mock", () => ({
+ handleDemoRequest: vi.fn(),
+}));
describe("dashboard client request helpers", () => {
const realFetch = globalThis.fetch;
@@ -17,6 +22,7 @@ describe("dashboard client request helpers", () => {
process.env.NEXT_PUBLIC_DEMO_MODE = realDemoMode;
}
vi.restoreAllMocks();
+ vi.mocked(handleDemoRequest).mockReset();
});
function jsonResponse(body: unknown, status = 200): Response {
@@ -178,12 +184,19 @@ describe("dashboard client request helpers", () => {
process.env.NEXT_PUBLIC_DEMO_MODE = "1";
const fetchMock = vi.fn();
globalThis.fetch = fetchMock;
+ vi.mocked(handleDemoRequest).mockReturnValue({ ok: true });
await expect(
fetchPrivateJsonMutate("/api/private/auth/login", "POST", undefined, {
username: "demo",
}),
).resolves.toMatchObject({ ok: true });
+ expect(handleDemoRequest).toHaveBeenCalledWith({
+ path: "/api/private/auth/login",
+ method: "POST",
+ params: undefined,
+ body: { username: "demo" },
+ });
expect(fetchMock).not.toHaveBeenCalled();
});
});
From 9f30af04714e6c63995b8b4f3744f9f7b5785715 Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:19:21 +0800
Subject: [PATCH 07/40] chore: add changelog for v0.2.0 release with API
enhancements and developer experience improvements
---
changelog/v0.2.0.md | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 changelog/v0.2.0.md
diff --git a/changelog/v0.2.0.md b/changelog/v0.2.0.md
new file mode 100644
index 00000000..8d09a4cd
--- /dev/null
+++ b/changelog/v0.2.0.md
@@ -0,0 +1,35 @@
+InsightFlare v0.2.0 expands the product from a dashboard-first analytics app into a more integration-ready analytics service with a documented API surface, API key authentication, and stronger request validation.
+
+This release focuses on making InsightFlare easier to connect with external tools and safer to expose programmatically. It introduces API key management, a versioned API contract, OpenAPI and agent discovery endpoints, and a more consistent API response model.
+
+## Highlights
+
+- Added API key management, including scoped keys, API key authentication, and dashboard controls for creating and managing access.
+- Introduced the versioned `/api/v1` API surface with generated OpenAPI 3.1 documentation and request/response examples.
+- Added `.well-known` discovery endpoints for OpenAPI, agent skills, health, security, and account password-change metadata.
+- Added generated `skills.json` documentation so LLM and agent clients can understand the public API more easily.
+- Added stronger request validation through shared Zod schemas for analytics, realtime, site, team, funnel, tracker, and common API inputs.
+- Improved API response consistency with request IDs, timestamps, and a unified response envelope.
+- Added same-origin request validation and strengthened secret/session handling for safer deployments.
+- Added dashboard surfaces for API key management and clearer version update details.
+- Improved geography and realtime dashboard rendering by moving heavier map and realtime views into dedicated client-side stages.
+
+## Changes Since v0.1.0
+
+- Fixed API version reporting so it is read from `package.json` instead of being hardcoded.
+- Fixed `/api/v1` root route matching with an optional catch-all route.
+- Aligned generated OpenAPI metadata, schemas, examples, and endpoint behavior with the actual implementation.
+- Fixed mobile page scrolling behavior.
+- Fixed rollback secret handling so fallback values are applied correctly.
+- Replaced hardcoded locale checks with the i18n system.
+- Renamed the funnel route to `funnels` for consistency.
+- Removed unnecessary release-summary height restrictions.
+- Reduced worker bundle pressure by moving heavier dashboard experiences behind client-only islands.
+
+## Developer Experience
+
+- Added scripts for generating OpenAPI and skills documentation.
+- Added an OpenAPI contract checker to catch documentation drift.
+- Added CI coverage thresholds and broadened test coverage across API v1, API keys, scheduled tasks, hourly rollups, realtime mocks, schemas, validation, and dashboard client data.
+- Added explicit Prettier defaults and stricter ESLint coverage for the codebase.
+- Added environment and prebuild checks for safer Cloudflare deployment flows.
From 1aa8f6c1ac37d33ad64322982c2a6e008e7a5c8d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 27 Jun 2026 11:19:44 +0000
Subject: [PATCH 08/40] chore(release): sync package version to v0.2.0 [skip
ci]
---
package-lock.json | 4 ++--
package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 7fe8929d..7b30152d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "insightflare",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "insightflare",
- "version": "0.1.0",
+ "version": "0.2.0",
"dependencies": {
"@deck.gl/core": "^9.3.2",
"@deck.gl/geo-layers": "^9.3.2",
diff --git a/package.json b/package.json
index dbc51061..2775b5c8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "insightflare",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"type": "module",
"packageManager": "npm@11.15.0",
From 4297941293aa74139de094786f4e56cba9ee1a3d Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 19:36:36 +0800
Subject: [PATCH 09/40] docs: add AI Agents integration section to README
---
.github/readme/README.en.md | 17 ++++++++++++++++-
.github/readme/README.zh.md | 17 ++++++++++++++++-
2 files changed, 32 insertions(+), 2 deletions(-)
diff --git a/.github/readme/README.en.md b/.github/readme/README.en.md
index e4651740..b7c1af89 100644
--- a/.github/readme/README.en.md
+++ b/.github/readme/README.en.md
@@ -142,6 +142,21 @@ After filling in the variables, wait about 3 minutes for the deployment to finis
## Advanced Configuration
+### Connect AI Agents for Analysis
+
+InsightFlare exposes Skills for AI Agents. You can connect your InsightFlare deployment to agents such as OpenClaw, Codex, Claude Code, and others, so they can access InsightFlare data directly for analysis and report generation.
+Send the following instruction to your Agent, replacing the domain with your deployed InsightFlare instance. Your Agent will guide you to the dashboard to create a dedicated API key for accessing InsightFlare data.
+
+```txt
+Read https:///.well-known/skills.json, connect to this web analytics system, and guide me through authorization.
+```
+
+Then you can ask your Agent questions in natural language, for example:
+
+```txt
+"How did my site perform last month? Where did most visitors come from among the highest-traffic sites? Which pages were the most popular?"
+```
+
### Override Wrangler Configuration with Cloudflare Variables
In Cloudflare build environments, you can use project variables and secrets to override deployment-specific values from `wrangler.toml`. `build:pre` reads these values before deployment, writes them into the active Wrangler config, and the following `wrangler deploy` uses the resolved config.
@@ -295,7 +310,7 @@ Set `NEXT_PUBLIC_DEMO_MODE=1` to make the development server automatically enabl
| Command | Purpose |
| --------------------------------- | ------------------------------------------- |
| `npm run dev` | Local dashboard development |
-| `npm run check` | Run typecheck + lint + format + i18n checks |
+| `npm run check` | Run typecheck + lint + format + i18n + tests + spec checks |
| `npm run typecheck` | TypeScript type checking |
| `npm run lint` / `lint:fix` | ESLint |
| `npm run format` / `format:check` | Prettier |
diff --git a/.github/readme/README.zh.md b/.github/readme/README.zh.md
index e46b65b2..65d128bc 100644
--- a/.github/readme/README.zh.md
+++ b/.github/readme/README.zh.md
@@ -142,6 +142,21 @@ Cloudflare 会自动 Clone 这个仓库、创建并绑定所需要的资源。
## 进阶配置
+### 接入 AI Agents 进行分析
+
+我们开放了用于 AI Agents 的 Skills,您可以选择将 InsightFlare 接入您的 Agents,例如 OpenClaw、Codex、Claude Code 等, 让 Agents 能够直接访问 InsightFlare 的数据,进行分析和报告生成。
+请直接发送下面的指令给您的 Agent,您需要域名换成您部署的 InsightFlare 实例。您的 Agents 会指引您前往仪表盘为其创建一个专用 API 密钥,便于其访问 InsightFlare 的数据。
+
+```txt
+阅读 https://<您的 InsightFlare 域名>/.well-known/skills.json,接入这个访问分析系统,并指引我进行授权。
+```
+
+随后,您可以以任意自然语言向 Agent 提问,例如:
+
+```txt
+“上个月,我的站点的访问情况如何?访问量最高的站点中,访客大都是来自哪里的?哪些页面最受欢迎?”
+```
+
### 使用 Cloudflare 变量覆盖 Wrangler 配置
在 Cloudflare 的构建环境中,可以通过「变量和密钥」覆盖 `wrangler.toml` 中需要因部署而变化的配置。`build:pre` 会在部署前读取这些变量并写入当前 Wrangler 配置,随后 `wrangler deploy` 会使用覆盖后的配置。
@@ -295,7 +310,7 @@ InsightFlare 的前端 SDK 支持以手动调用的方式上报自定义事件
| 命令 | 用途 |
| --------------------------------- | ---------------------------------------------- |
| `npm run dev` | 本地开发仪表板 |
-| `npm run check` | 一键执行 typecheck + lint + format + i18n 校验 |
+| `npm run check` | 一键执行 typecheck + lint + format + i18n + test + spec 校验 |
| `npm run typecheck` | TypeScript 类型检查 |
| `npm run lint` / `lint:fix` | ESLint |
| `npm run format` / `format:check` | Prettier |
From ef4fb94677f67e9b691f9362fde8085ca2c28327 Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 20:14:12 +0800
Subject: [PATCH 10/40] fix: sync release api discovery artifacts
---
.github/workflows/release.yml | 23 +++++++++--
docs/openapi.json | 6 +--
docs/openapi.yaml | 6 +--
docs/skills.json | 4 +-
eslint.config.js | 2 +-
scripts/generate-skills.ts | 2 +-
src/app/.well-known/openapi.json/route.ts | 4 +-
src/app/.well-known/skills.json/route.ts | 4 +-
.../well-known-discovery-routes.test.ts | 39 +++++++++++++++++++
9 files changed, 74 insertions(+), 16 deletions(-)
create mode 100644 src/app/__tests__/well-known-discovery-routes.test.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f18ac4e7..f439ef11 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -47,7 +47,18 @@ jobs:
echo "file_path=$NEW_FILE" >> "$GITHUB_OUTPUT"
echo "Detected release changelog: $NEW_FILE"
- - name: Sync package versions
+ - name: Setup Node.js
+ if: steps.changelog.outputs.has_release == 'true'
+ uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ cache: npm
+
+ - name: Install dependencies
+ if: steps.changelog.outputs.has_release == 'true'
+ run: npm ci
+
+ - name: Sync release artifacts
if: steps.changelog.outputs.has_release == 'true'
shell: bash
run: |
@@ -83,16 +94,20 @@ jobs:
updatePackageLock("package-lock.json");
NODE
- git add package.json package-lock.json
+ npm run generate:openapi
+ npm run generate:skills
+ node scripts/check-openapi-contract.mjs
+
+ git add package.json package-lock.json docs/openapi.json docs/openapi.yaml docs/skills.json
if git diff --cached --quiet; then
- echo "Package versions are already up to date."
+ echo "Release artifacts are already up to date."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git commit -m "chore(release): sync package version to ${{ steps.changelog.outputs.version }} [skip ci]"
+ git commit -m "chore(release): sync release artifacts to ${{ steps.changelog.outputs.version }} [skip ci]"
git push origin HEAD:${{ github.ref_name }}
- name: Finalize release commit
diff --git a/docs/openapi.json b/docs/openapi.json
index 5eb1f855..51bc82d7 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "InsightFlare API",
"description": "Privacy-focused web analytics API. Authenticated endpoints require an API key passed as a Bearer token in the Authorization header. All timestamps in query parameters and response objects are ISO 8601 date-time strings unless the field name explicitly ends with `Ms`. Fields ending with `Ms` represent millisecond values, such as durations or Unix timestamps depending on context. Analytics ranges use [from, to) semantics. If from, to, and preset are omitted, analytics endpoints default to the last 7 days ending at request time. The default timeZone is UTC.\n\nThis OpenAPI document describes the behavior of the InsightFlare origin API. Depending on deployment configuration, upstream infrastructure, proxies, gateways, or edge providers may return additional HTTP responses before requests reach the API origin, such as 429 Too Many Requests. These responses are outside the standard API error envelope and are not part of the stable API contract.",
- "version": "0.1.0",
+ "version": "0.2.0",
"contact": {
"name": "InsightFlare",
"url": "https://github.com/ravelloh/InsightFlare"
@@ -232,7 +232,7 @@
"summary": "getApiRoot",
"value": {
"data": {
- "version": "0.1.0",
+ "version": "0.2.0",
"service": "InsightFlare Analytics API",
"links": {
"self": "/api/v1",
@@ -408,7 +408,7 @@
"summary": "getCapabilities",
"value": {
"data": {
- "apiVersion": "0.1.0",
+ "apiVersion": "0.2.0",
"features": {
"sites": true,
"tracking": true,
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 33ba8f72..a9c34664 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -18,7 +18,7 @@ info:
requests reach the API origin, such as 429 Too Many Requests. These
responses are outside the standard API error envelope and are not part of
the stable API contract.
- version: 0.1.0
+ version: 0.2.0
contact:
name: InsightFlare
url: https://github.com/ravelloh/InsightFlare
@@ -179,7 +179,7 @@ paths:
summary: getApiRoot
value:
data:
- version: 0.1.0
+ version: 0.2.0
service: InsightFlare Analytics API
links:
self: /api/v1
@@ -304,7 +304,7 @@ paths:
summary: getCapabilities
value:
data:
- apiVersion: 0.1.0
+ apiVersion: 0.2.0
features:
sites: true
tracking: true
diff --git a/docs/skills.json b/docs/skills.json
index 0624d89e..c5d8e408 100644
--- a/docs/skills.json
+++ b/docs/skills.json
@@ -1,8 +1,8 @@
{
"api": "InsightFlare Analytics API",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Privacy-focused web analytics platform.",
- "baseUrl": "https://insight.ravelloh.com",
+ "baseUrl": "${baseUrl}",
"openapiUrl": "/.well-known/openapi.json",
"discovery": {
"root": "/api/v1",
diff --git a/eslint.config.js b/eslint.config.js
index 327dd457..9c0e1584 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -110,7 +110,7 @@ export default [
languageOptions: {
parserOptions: {
projectService: {
- allowDefaultProject: ["*.ts", "src/app/.well-known/*/route.ts"],
+ allowDefaultProject: ["*.ts"],
},
tsconfigRootDir: import.meta.dirname,
},
diff --git a/scripts/generate-skills.ts b/scripts/generate-skills.ts
index 618cad0d..4dd233b4 100644
--- a/scripts/generate-skills.ts
+++ b/scripts/generate-skills.ts
@@ -17,7 +17,7 @@ function generate() {
api: "InsightFlare Analytics API",
version: getAppVersion(),
description: "Privacy-focused web analytics platform.",
- baseUrl: "https://insight.ravelloh.com",
+ baseUrl: "${baseUrl}",
openapiUrl: "/.well-known/openapi.json",
discovery: {
root: "/api/v1",
diff --git a/src/app/.well-known/openapi.json/route.ts b/src/app/.well-known/openapi.json/route.ts
index b6ec1a99..2dc39d9a 100644
--- a/src/app/.well-known/openapi.json/route.ts
+++ b/src/app/.well-known/openapi.json/route.ts
@@ -7,8 +7,10 @@ const HEADERS = {
};
function getBaseUrl(request: Request): string {
- const host = request.headers.get("host");
+ const host =
+ request.headers.get("x-forwarded-host") ?? request.headers.get("host");
const proto = request.headers.get("x-forwarded-proto") ?? "https";
+ if (!host) return new URL(request.url).origin;
return `${proto}://${host}`;
}
diff --git a/src/app/.well-known/skills.json/route.ts b/src/app/.well-known/skills.json/route.ts
index bc6a9ca6..239785fa 100644
--- a/src/app/.well-known/skills.json/route.ts
+++ b/src/app/.well-known/skills.json/route.ts
@@ -7,8 +7,10 @@ const HEADERS = {
};
function getBaseUrl(request: Request): string {
- const host = request.headers.get("host");
+ const host =
+ request.headers.get("x-forwarded-host") ?? request.headers.get("host");
const proto = request.headers.get("x-forwarded-proto") ?? "https";
+ if (!host) return new URL(request.url).origin;
return `${proto}://${host}`;
}
diff --git a/src/app/__tests__/well-known-discovery-routes.test.ts b/src/app/__tests__/well-known-discovery-routes.test.ts
new file mode 100644
index 00000000..e0ec176d
--- /dev/null
+++ b/src/app/__tests__/well-known-discovery-routes.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+
+import { GET as getOpenApi } from "@/app/.well-known/openapi.json/route";
+import { GET as getSkills } from "@/app/.well-known/skills.json/route";
+
+describe(".well-known discovery routes", () => {
+ it("resolves the Skills base URL from forwarded request headers", async () => {
+ const response = getSkills(
+ new Request("http://internal.test/.well-known/skills.json", {
+ headers: {
+ host: "internal.test",
+ "x-forwarded-host": "analytics.example.test",
+ "x-forwarded-proto": "https",
+ },
+ }),
+ );
+
+ await expect(response.json()).resolves.toMatchObject({
+ baseUrl: "https://analytics.example.test",
+ });
+ });
+
+ it("resolves OpenAPI servers from forwarded request headers", async () => {
+ const response = getOpenApi(
+ new Request("http://internal.test/.well-known/openapi.json", {
+ headers: {
+ host: "internal.test",
+ "x-forwarded-host": "analytics.example.test",
+ "x-forwarded-proto": "https",
+ },
+ }),
+ );
+ const body = await response.json();
+
+ expect(body.servers).toEqual([
+ expect.objectContaining({ url: "https://analytics.example.test" }),
+ ]);
+ });
+});
From d38e6cd28cdab33b88dccc44a9853f30f852ab7a Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 21:20:32 +0800
Subject: [PATCH 11/40] Implement API v1 analytics endpoints
---
.../well-known-discovery-routes.test.ts | 4 +-
src/lib/edge/__tests__/api-v1.test.ts | 81 +-
src/lib/edge/api-v1.ts | 701 +++++++++++++++++-
3 files changed, 763 insertions(+), 23 deletions(-)
diff --git a/src/app/__tests__/well-known-discovery-routes.test.ts b/src/app/__tests__/well-known-discovery-routes.test.ts
index e0ec176d..4164c21f 100644
--- a/src/app/__tests__/well-known-discovery-routes.test.ts
+++ b/src/app/__tests__/well-known-discovery-routes.test.ts
@@ -30,7 +30,9 @@ describe(".well-known discovery routes", () => {
},
}),
);
- const body = await response.json();
+ const body = (await response.json()) as {
+ servers?: Array<{ url?: string }>;
+ };
expect(body.servers).toEqual([
expect.objectContaining({ url: "https://analytics.example.test" }),
diff --git a/src/lib/edge/__tests__/api-v1.test.ts b/src/lib/edge/__tests__/api-v1.test.ts
index a173fff3..f8f8487c 100644
--- a/src/lib/edge/__tests__/api-v1.test.ts
+++ b/src/lib/edge/__tests__/api-v1.test.ts
@@ -1382,7 +1382,13 @@ describe("api v1 gateway", () => {
it("returns explore analytics via POST", async () => {
const { response } = await authed(
"/api/v1/sites/site-1/analytics/explore?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z",
- [siteMatch("site-1", "Blog")],
+ [
+ siteMatch("site-1", "Blog"),
+ {
+ includes: ["event_rollup", "GROUP BY scoped.d0"],
+ all: [{ d0: "/pricing", views: 5 }],
+ },
+ ],
{
method: "POST",
headers: { "content-type": "application/json" },
@@ -1399,6 +1405,9 @@ describe("api v1 gateway", () => {
};
expect(body.data.metrics).toEqual(["views"]);
expect(body.data.dimensions).toEqual(["page.path"]);
+ expect(body.data).toMatchObject({
+ rows: [{ "page.path": "/pricing", views: 5 }],
+ });
});
it("rejects explore POST with invalid complex filters", async () => {
@@ -1831,16 +1840,63 @@ describe("api v1 gateway", () => {
// ── additional coverage: performance ────────────────────────────
it("returns performance data", async () => {
- routeQueryMock.mockResolvedValueOnce(
- new Response(JSON.stringify({ ok: true, data: { ttfb: 100 } }), {
- headers: { "content-type": "application/json" },
- }),
+ const { response } = await authed(
+ "/api/v1/sites/site-1/performance/summary?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z",
+ [
+ siteMatch("site-1", "Blog"),
+ {
+ includes: ["metric_thresholds", "thresholds.metric"],
+ all: [
+ {
+ metric: "ttfb",
+ samples: 3,
+ avgValue: 110,
+ p50: 100,
+ p75: 120,
+ p95: 150,
+ },
+ ],
+ },
+ ],
);
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ data: { ttfb: 120, fcp: null, lcp: null, cls: null, inp: null },
+ });
+ });
+
+ it("returns performance breakdowns by documented dimension", async () => {
const { response } = await authed(
- "/api/v1/sites/site-1/performance?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z",
- [siteMatch("site-1", "Blog")],
+ "/api/v1/sites/site-1/performance/breakdowns/page.path?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z&metric=lcp",
+ [
+ siteMatch("site-1", "Blog"),
+ {
+ includes: ["dimension_views", "thresholds.dimensionValue"],
+ all: [
+ {
+ dimensionValue: "/pricing",
+ views: 7,
+ samples: 4,
+ avg: 1500,
+ p50: 1200,
+ p75: 1800,
+ p95: 2100,
+ },
+ ],
+ },
+ ],
);
expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ data: [
+ {
+ key: "/pricing",
+ label: "/pricing",
+ lcp: 1800,
+ samples: 4,
+ },
+ ],
+ });
});
it("rejects non-GET on performance endpoint", async () => {
@@ -1937,12 +1993,21 @@ describe("api v1 gateway", () => {
// ── additional coverage: team sub-resources ─────────────────────
it("returns team analytics breakdowns", async () => {
- const matches = [teamSitesListMatch([{ id: "site-1", name: "One" }])];
+ const matches = [
+ sitesListMatch([{ id: "site-1", name: "One" }]),
+ {
+ includes: ["event_rollup"],
+ all: [{ d0: "US", views: 12, sessions: 8, visitors: 6 }],
+ },
+ ];
const { response } = await authed(
"/api/v1/team/analytics/breakdowns/geo.country?from=2026-06-01T00:00:00Z&to=2026-06-02T00:00:00Z",
matches,
);
expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ data: [{ key: "US", label: "US", views: 12, sessions: 8, visitors: 6 }],
+ });
});
it("returns 404 for unknown team analytics resource", async () => {
diff --git a/src/lib/edge/api-v1.ts b/src/lib/edge/api-v1.ts
index 4cb25ccc..d0925c21 100644
--- a/src/lib/edge/api-v1.ts
+++ b/src/lib/edge/api-v1.ts
@@ -8,7 +8,26 @@ import {
import { SiteCreateInputSchema, SiteUpdateInputSchema } from "@/schemas/site";
import { SiteConfigUpdateInputSchema } from "@/schemas/site-config";
+import {
+ buildTimeBuckets,
+ buildVisitFilterSql,
+ buildVisitSourceCte,
+ buildVisitSourceCteForSites,
+ parseFilters,
+ parseInterval,
+ PERFORMANCE_METRIC_COLUMNS,
+ type PerformanceMetricKey,
+ queryD1All,
+ type QueryWindow,
+ resolveCrossBreakdownDimension,
+ visitSourceBindings,
+ visitSourceBindingsForSites,
+} from "./query/core";
import { normalizeFunnelSteps, queryFunnelAnalysis } from "./query/funnels";
+import {
+ queryPerformanceSummariesFromD1,
+ queryPerformanceTrendFromD1,
+} from "./query/performance";
import { routeQuery } from "./query/router";
import { handleTeamDashboardForTeam } from "./query/team";
import {
@@ -29,6 +48,7 @@ import {
type AnalyticsMetric,
API_V1_VERSION,
BATCH_MAX_REQUESTS,
+ type ComplexFilter,
epochSecondsToIso,
FILTER_OPERATORS,
INTERVALS,
@@ -550,6 +570,568 @@ function normalizeBreakdownRows(value: unknown, metrics: AnalyticsMetric[]) {
});
}
+interface AnalyticsOrderBy {
+ field: string;
+ direction: "asc" | "desc";
+}
+
+function sqlWhereWithExtra(baseClause: string, extraClause: string): string {
+ if (!extraClause) return baseClause;
+ if (baseClause.trim()) return `${baseClause} AND ${extraClause}`;
+ return `WHERE ${extraClause}`;
+}
+
+function analyticsMetricSql(metric: AnalyticsMetric): string {
+ const sessions =
+ "COUNT(DISTINCT CASE WHEN scoped.session_id != '' THEN scoped.session_id ELSE NULL END)";
+ const bounces =
+ "COUNT(DISTINCT CASE WHEN bounced_sessions.session_id IS NOT NULL THEN scoped.session_id ELSE NULL END)";
+
+ if (metric === "views") return "COUNT(*)";
+ if (metric === "sessions") return sessions;
+ if (metric === "visitors") {
+ return "COUNT(DISTINCT CASE WHEN scoped.visitor_id != '' THEN scoped.visitor_id ELSE NULL END)";
+ }
+ if (metric === "bounces") return bounces;
+ if (metric === "bounceRate") {
+ return `CASE WHEN ${sessions} > 0 THEN CAST(${bounces} AS REAL) / ${sessions} ELSE 0 END`;
+ }
+ if (metric === "avgDurationMs") {
+ return `CASE WHEN ${sessions} > 0 THEN ROUND(COALESCE(SUM(CASE WHEN scoped.duration_ms IS NOT NULL AND scoped.duration_ms >= 0 THEN scoped.duration_ms ELSE 0 END), 0) / ${sessions}) ELSE 0 END`;
+ }
+ if (metric === "viewsPerSession") {
+ return `CASE WHEN ${sessions} > 0 THEN CAST(COUNT(*) AS REAL) / ${sessions} ELSE 0 END`;
+ }
+ return "COALESCE(SUM(event_rollup.event_count), 0)";
+}
+
+function validateAnalyticsDimensions(
+ dimensions: string[],
+ request: Request,
+): Response | null {
+ for (const dimension of dimensions) {
+ const valid = validateDimension(dimension);
+ if (valid instanceof Response) return valid;
+ if (!resolveCrossBreakdownDimension(dimension)) {
+ return jsonError(
+ "validation_failed",
+ "Unsupported dimension",
+ 400,
+ { dimension },
+ request,
+ );
+ }
+ }
+ return null;
+}
+
+function parseExploreMetrics(value: unknown): AnalyticsMetric[] | Response {
+ if (value === undefined) return ["views"];
+ if (!Array.isArray(value) || value.length === 0 || value.length > 20) {
+ return jsonError("validation_failed", "Invalid metrics", 400, {
+ field: "metrics",
+ });
+ }
+ const invalid = value.find(
+ (metric) =>
+ typeof metric !== "string" ||
+ !ANALYTICS_METRICS.includes(metric as AnalyticsMetric),
+ );
+ if (invalid !== undefined) {
+ return jsonError("validation_failed", "Unknown metric", 400, {
+ metric: String(invalid),
+ });
+ }
+ return [...new Set(value)] as AnalyticsMetric[];
+}
+
+function parseExploreDimensions(value: unknown): string[] | Response {
+ if (value === undefined) return [];
+ if (!Array.isArray(value) || value.length > 5) {
+ return jsonError("validation_failed", "Invalid dimensions", 400, {
+ field: "dimensions",
+ });
+ }
+ const invalid = value.find((dimension) => typeof dimension !== "string");
+ if (invalid !== undefined) {
+ return jsonError("validation_failed", "Invalid dimension", 400, {
+ dimension: String(invalid),
+ });
+ }
+ return [...new Set(value)] as string[];
+}
+
+function parseExploreOrderBy(value: unknown): AnalyticsOrderBy[] | Response {
+ if (value === undefined) return [];
+ if (!Array.isArray(value) || value.length > 5) {
+ return jsonError("validation_failed", "Invalid orderBy", 400, {
+ field: "orderBy",
+ });
+ }
+ const orderBy: AnalyticsOrderBy[] = [];
+ for (const item of value) {
+ if (!item || typeof item !== "object") {
+ return jsonError("validation_failed", "Invalid orderBy", 400);
+ }
+ const record = item as Record;
+ const field = typeof record.field === "string" ? record.field : "";
+ const direction = record.direction === "asc" ? "asc" : "desc";
+ if (!field) {
+ return jsonError("validation_failed", "Invalid orderBy field", 400);
+ }
+ orderBy.push({ field, direction });
+ }
+ return orderBy;
+}
+
+function parseExploreLimit(value: unknown): number | Response {
+ if (value === undefined) return 100;
+ const limit = Number(value);
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
+ return jsonError("validation_failed", "Invalid limit", 400, {
+ field: "limit",
+ });
+ }
+ return limit;
+}
+
+function urlWithBodyTimeRange(url: URL, record: Record): URL {
+ const timeRange =
+ record.timeRange && typeof record.timeRange === "object"
+ ? (record.timeRange as Record)
+ : null;
+ if (!timeRange) return url;
+ const next = new URL(url.toString());
+ for (const key of ["from", "to", "preset", "timeZone"] as const) {
+ if (typeof timeRange[key] === "string") {
+ next.searchParams.set(key, timeRange[key]);
+ }
+ }
+ return next;
+}
+
+function complexFilterSql(
+ filters: ComplexFilter[],
+ request: Request,
+): { clause: string; bindings: Array } | Response {
+ const clauses: string[] = [];
+ const bindings: Array = [];
+ for (const filter of filters) {
+ const definition = resolveCrossBreakdownDimension(filter.field);
+ if (!definition) {
+ return jsonError(
+ "validation_failed",
+ "Unsupported filter field",
+ 400,
+ { field: filter.field },
+ request,
+ );
+ }
+ const expr = definition.labelExpr;
+ const value = filter.value;
+ const bindScalar = (raw: unknown) => {
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
+ if (typeof raw === "boolean") return raw ? 1 : 0;
+ if (raw === null || raw === undefined) return "";
+ return String(raw);
+ };
+
+ if (filter.op === "exists") {
+ clauses.push(`TRIM(COALESCE(${expr}, '')) != ''`);
+ continue;
+ }
+ if (filter.op === "notExists") {
+ clauses.push(`TRIM(COALESCE(${expr}, '')) = ''`);
+ continue;
+ }
+ if (filter.op === "in" || filter.op === "notIn") {
+ const values = Array.isArray(value) ? value : [];
+ if (values.length === 0) {
+ clauses.push(filter.op === "in" ? "1 = 0" : "1 = 1");
+ continue;
+ }
+ clauses.push(
+ `${expr} ${filter.op === "in" ? "IN" : "NOT IN"} (${values
+ .map(() => "?")
+ .join(", ")})`,
+ );
+ bindings.push(...values.map(bindScalar));
+ continue;
+ }
+ if (filter.op === "contains") {
+ clauses.push(`${expr} LIKE ?`);
+ bindings.push(`%${bindScalar(value)}%`);
+ continue;
+ }
+ if (filter.op === "startsWith") {
+ clauses.push(`${expr} LIKE ?`);
+ bindings.push(`${bindScalar(value)}%`);
+ continue;
+ }
+ if (filter.op === "endsWith") {
+ clauses.push(`${expr} LIKE ?`);
+ bindings.push(`%${bindScalar(value)}`);
+ continue;
+ }
+ const operator =
+ filter.op === "neq"
+ ? "!="
+ : filter.op === "gt"
+ ? ">"
+ : filter.op === "gte"
+ ? ">="
+ : filter.op === "lt"
+ ? "<"
+ : filter.op === "lte"
+ ? "<="
+ : "=";
+ clauses.push(`${expr} ${operator} ?`);
+ bindings.push(bindScalar(value));
+ }
+ return { clause: clauses.join(" AND "), bindings };
+}
+
+async function queryAnalyticsAggregateRows(
+ env: Env,
+ siteIds: string[],
+ window: QueryWindow,
+ url: URL,
+ request: Request,
+ options: {
+ dimensions: string[];
+ metrics: AnalyticsMetric[];
+ complexFilters?: ComplexFilter[];
+ limit: number;
+ orderBy?: AnalyticsOrderBy[];
+ },
+): Promise> | Response> {
+ if (siteIds.length === 0) return [];
+ const invalidDimension = validateAnalyticsDimensions(
+ options.dimensions,
+ request,
+ );
+ if (invalidDimension) return invalidDimension;
+
+ const dimensionDefs = options.dimensions.map((dimension) => ({
+ dimension,
+ definition: resolveCrossBreakdownDimension(dimension)!,
+ }));
+ const filters = buildVisitFilterSql(parseFilters(url));
+ const complex = complexFilterSql(options.complexFilters ?? [], request);
+ if (complex instanceof Response) return complex;
+ const whereClause = sqlWhereWithExtra(filters.clause, complex.clause);
+ const sourceCte =
+ siteIds.length === 1
+ ? buildVisitSourceCte()
+ : buildVisitSourceCteForSites(siteIds.length);
+ const sourceBindings =
+ siteIds.length === 1
+ ? visitSourceBindings(siteIds[0]!, window)
+ : visitSourceBindingsForSites(siteIds, window);
+ const eventSitePlaceholders = siteIds.map(() => "?").join(", ");
+ const dimensionSelects = dimensionDefs.map(
+ ({ definition }, index) => `${definition.labelExpr} AS d${index}`,
+ );
+ const groupColumns = dimensionDefs.map((_, index) => `scoped.d${index}`);
+ const metricSelects = options.metrics.map(
+ (metric) => `${analyticsMetricSql(metric)} AS ${metric}`,
+ );
+ const selectColumns = [...groupColumns, ...metricSelects];
+ const allowedOrderFields = new Set([
+ ...options.metrics,
+ ...options.dimensions,
+ ]);
+ const orderBy = (options.orderBy ?? []).filter((item) =>
+ allowedOrderFields.has(item.field as AnalyticsMetric),
+ );
+ const orderSql =
+ orderBy.length > 0
+ ? orderBy
+ .map((item) => {
+ const dimensionIndex = options.dimensions.indexOf(item.field);
+ const column =
+ dimensionIndex >= 0 ? `scoped.d${dimensionIndex}` : item.field;
+ return `${column} ${item.direction.toUpperCase()}`;
+ })
+ .join(", ")
+ : options.metrics.length > 0
+ ? `${options.metrics[0]} DESC`
+ : groupColumns.join(", ");
+ const sql = `
+WITH
+${sourceCte},
+scoped AS (
+ SELECT
+ visit_source.*
+ ${dimensionSelects.length ? `,\n ${dimensionSelects.join(",\n ")}` : ""}
+ FROM visit_source
+ ${whereClause}
+),
+bounced_sessions AS (
+ SELECT session_id
+ FROM visit_source
+ WHERE session_id != ''
+ GROUP BY session_id
+ HAVING COUNT(*) = 1
+),
+event_rollup AS (
+ SELECT visit_id, COUNT(*) AS event_count
+ FROM custom_events
+ WHERE site_id IN (${eventSitePlaceholders}) AND occurred_at BETWEEN ? AND ?
+ GROUP BY visit_id
+)
+SELECT
+ ${selectColumns.join(",\n ")}
+FROM scoped
+LEFT JOIN bounced_sessions ON bounced_sessions.session_id = scoped.session_id
+LEFT JOIN event_rollup ON event_rollup.visit_id = scoped.visit_id
+${groupColumns.length ? `GROUP BY ${groupColumns.join(", ")}` : ""}
+ORDER BY ${orderSql || "views DESC"}
+LIMIT ?
+`;
+ const rows = await queryD1All>(env, sql, [
+ ...sourceBindings,
+ ...filters.bindings,
+ ...complex.bindings,
+ ...siteIds,
+ window.fromMs,
+ window.toMs,
+ options.limit,
+ ]);
+ return rows.map((row) => {
+ const out: Record = {};
+ options.dimensions.forEach((dimension, index) => {
+ out[dimension] = String(row[`d${index}`] ?? "");
+ });
+ for (const metric of options.metrics) {
+ out[metric] = Number(row[metric] ?? 0);
+ }
+ return out;
+ });
+}
+
+async function queryTeamAnalyticsBreakdown(
+ env: Env,
+ siteIds: string[],
+ window: QueryWindow,
+ url: URL,
+ request: Request,
+ dimension: AnalyticsDimension,
+ metrics: AnalyticsMetric[],
+): Promise> | Response> {
+ const limit = parseExploreLimit(Number(url.searchParams.get("limit") ?? 100));
+ if (limit instanceof Response) return limit;
+ const rows = await queryAnalyticsAggregateRows(
+ env,
+ siteIds,
+ window,
+ url,
+ request,
+ {
+ dimensions: [dimension],
+ metrics,
+ limit,
+ },
+ );
+ if (rows instanceof Response) return rows;
+ return rows.map((row) => {
+ const normalized = normalizeUnknownDirect(row[dimension]);
+ const metricsOut: Record = {};
+ for (const metric of metrics) metricsOut[metric] = row[metric];
+ return {
+ key: normalized.key,
+ label: normalized.label,
+ ...metricsOut,
+ };
+ });
+}
+
+function parsePerformanceMetric(url: URL): PerformanceMetricKey | Response {
+ const metric = url.searchParams.get("metric") || "lcp";
+ if (metric in PERFORMANCE_METRIC_COLUMNS)
+ return metric as PerformanceMetricKey;
+ return jsonError("validation_failed", "Invalid performance metric", 400, {
+ metric,
+ });
+}
+
+function performanceSummaryValue(row: {
+ p75: number | null;
+ avg: number | null;
+}): number | null {
+ return row.p75 ?? row.avg;
+}
+
+async function queryPerformanceSummaryData(
+ env: Env,
+ siteId: string,
+ window: QueryWindow,
+ url: URL,
+) {
+ const summaries = await queryPerformanceSummariesFromD1(
+ env,
+ siteId,
+ window,
+ parseFilters(url),
+ );
+ return {
+ ttfb: performanceSummaryValue(summaries.ttfb),
+ fcp: performanceSummaryValue(summaries.fcp),
+ lcp: performanceSummaryValue(summaries.lcp),
+ cls: performanceSummaryValue(summaries.cls),
+ inp: performanceSummaryValue(summaries.inp),
+ details: summaries,
+ };
+}
+
+async function queryPerformanceTimeseriesData(
+ env: Env,
+ siteId: string,
+ window: QueryWindow,
+ url: URL,
+) {
+ const filters = parseFilters(url);
+ const interval = parseInterval(url);
+ const buckets = buildTimeBuckets(window, interval);
+ const metricKeys = Object.keys(
+ PERFORMANCE_METRIC_COLUMNS,
+ ) as PerformanceMetricKey[];
+ const series = await Promise.all(
+ metricKeys.map((metric) =>
+ queryPerformanceTrendFromD1(
+ env,
+ siteId,
+ window,
+ interval,
+ filters,
+ metric,
+ ),
+ ),
+ );
+ const rows = new Map>();
+ for (const [metricIndex, points] of series.entries()) {
+ const metric = metricKeys[metricIndex]!;
+ for (const point of points) {
+ const bucket = buckets[point.bucket] ?? {
+ timestampMs: point.timestampMs,
+ toMs: point.timestampMs + 1,
+ };
+ const row =
+ rows.get(point.bucket) ??
+ ({
+ start: new Date(bucket.timestampMs).toISOString(),
+ end: new Date(bucket.toMs).toISOString(),
+ } satisfies Record);
+ row[metric] = performanceSummaryValue(point);
+ rows.set(point.bucket, row);
+ }
+ }
+ return {
+ interval,
+ rows: [...rows.entries()]
+ .sort((left, right) => left[0] - right[0])
+ .map(([, row]) => row),
+ };
+}
+
+async function queryPerformanceBreakdownData(
+ env: Env,
+ siteId: string,
+ window: QueryWindow,
+ url: URL,
+ request: Request,
+ dimension: AnalyticsDimension,
+): Promise> | Response> {
+ const metric = parsePerformanceMetric(url);
+ if (metric instanceof Response) return metric;
+ const definition = resolveCrossBreakdownDimension(dimension);
+ if (!definition) {
+ return jsonError(
+ "validation_failed",
+ "Unsupported performance breakdown dimension",
+ 400,
+ { dimension },
+ request,
+ );
+ }
+ const filters = buildVisitFilterSql(parseFilters(url));
+ const whereClause = sqlWhereWithExtra(
+ filters.clause,
+ `${PERFORMANCE_METRIC_COLUMNS[metric]} IS NOT NULL`,
+ );
+ const limit = parseExploreLimit(Number(url.searchParams.get("limit") ?? 100));
+ if (limit instanceof Response) return limit;
+ const sql = `
+WITH
+${buildVisitSourceCte()},
+scoped AS (
+ SELECT
+ ${definition.labelExpr} AS dimensionValue,
+ ${PERFORMANCE_METRIC_COLUMNS[metric]} AS metricValue
+ FROM visit_source
+ ${whereClause}
+),
+dimension_views AS (
+ SELECT dimensionValue, COUNT(*) AS views
+ FROM scoped
+ GROUP BY dimensionValue
+),
+ordered_values AS (
+ SELECT
+ dimensionValue,
+ metricValue,
+ ROW_NUMBER() OVER (PARTITION BY dimensionValue ORDER BY metricValue ASC) AS rowNum,
+ COUNT(*) OVER (PARTITION BY dimensionValue) AS sampleCount
+ FROM scoped
+),
+thresholds AS (
+ SELECT
+ dimensionValue,
+ sampleCount,
+ AVG(metricValue) AS avgValue,
+ CAST(((sampleCount * 50) + 99) / 100 AS INTEGER) AS p50Rank,
+ CAST(((sampleCount * 75) + 99) / 100 AS INTEGER) AS p75Rank,
+ CAST(((sampleCount * 95) + 99) / 100 AS INTEGER) AS p95Rank
+ FROM ordered_values
+ GROUP BY dimensionValue, sampleCount
+)
+SELECT
+ thresholds.dimensionValue AS dimensionValue,
+ dimension_views.views AS views,
+ thresholds.sampleCount AS samples,
+ thresholds.avgValue AS avg,
+ MIN(CASE WHEN ordered_values.rowNum >= thresholds.p50Rank THEN ordered_values.metricValue END) AS p50,
+ MIN(CASE WHEN ordered_values.rowNum >= thresholds.p75Rank THEN ordered_values.metricValue END) AS p75,
+ MIN(CASE WHEN ordered_values.rowNum >= thresholds.p95Rank THEN ordered_values.metricValue END) AS p95
+FROM thresholds
+JOIN ordered_values ON ordered_values.dimensionValue = thresholds.dimensionValue
+JOIN dimension_views ON dimension_views.dimensionValue = thresholds.dimensionValue
+GROUP BY thresholds.dimensionValue, thresholds.sampleCount, thresholds.avgValue, dimension_views.views
+ORDER BY p75 DESC, views DESC, thresholds.dimensionValue ASC
+LIMIT ?
+`;
+ const rows = await queryD1All>(env, sql, [
+ ...visitSourceBindings(siteId, window),
+ ...filters.bindings,
+ limit,
+ ]);
+ return rows.map((row) => {
+ const normalized = normalizeUnknownDirect(row.dimensionValue);
+ const p75 = Number(row.p75 ?? 0);
+ return {
+ key: normalized.key,
+ label: normalized.label,
+ views: Number(row.views ?? 0),
+ [metric]: p75,
+ avg: Number(row.avg ?? 0),
+ p50: Number(row.p50 ?? 0),
+ p75,
+ p95: Number(row.p95 ?? 0),
+ samples: Number(row.samples ?? 0),
+ };
+ });
+}
+
function normalizeTimeseriesRows(value: unknown) {
if (!Array.isArray(value)) return [];
return value.map((row) => {
@@ -815,6 +1397,25 @@ async function handleTeamAnalytics(
const resource = path[2];
const timeRange = parseTimeRange(url);
if (timeRange instanceof Response) return timeRange;
+ if (resource === "breakdowns" && path[3]) {
+ const dimension = validateDimension(path[3]);
+ if (dimension instanceof Response) return dimension;
+ const metrics = parseMetrics(url.searchParams.get("metrics"));
+ if (metrics instanceof Response) return metrics;
+ const sites = await listSites(env, principal);
+ const internalUrl = buildInternalUrl(url, timeRange);
+ const rows = await queryTeamAnalyticsBreakdown(
+ env,
+ sites.map((site) => site.id),
+ toQueryWindow(timeRange),
+ internalUrl,
+ request,
+ dimension,
+ metrics,
+ );
+ if (rows instanceof Response) return rows;
+ return jsonList(rows, { request, meta: { timeRange, dimension, metrics } });
+ }
const internalUrl = buildInternalUrl(url, timeRange);
const dashboard = await handleTeamDashboardForTeam(
env,
@@ -932,11 +1533,6 @@ async function handleTeamAnalytics(
{ request, meta: { timeRange } },
);
}
- if (resource === "breakdowns" && path[3]) {
- const dimension = validateDimension(path[3]);
- if (dimension instanceof Response) return dimension;
- return jsonList([], { request, meta: { timeRange, dimension } });
- }
return jsonError(
"resource_not_found",
"Resource not found",
@@ -1404,16 +2000,44 @@ async function handleAnalytics(
if (body instanceof Response) return body;
const record =
body && typeof body === "object" ? (body as Record) : {};
+ const bodyUrl = urlWithBodyTimeRange(url, record);
+ const exploreTimeRange = parseTimeRange(bodyUrl);
+ if (exploreTimeRange instanceof Response) return exploreTimeRange;
+ const metrics = parseExploreMetrics(record.metrics);
+ if (metrics instanceof Response) return metrics;
+ const dimensions = parseExploreDimensions(record.dimensions);
+ if (dimensions instanceof Response) return dimensions;
+ const invalidDimension = validateAnalyticsDimensions(dimensions, request);
+ if (invalidDimension) return invalidDimension;
const complexFilters = parseComplexFilters(record.filters);
if (complexFilters instanceof Response) return complexFilters;
+ const orderBy = parseExploreOrderBy(record.orderBy);
+ if (orderBy instanceof Response) return orderBy;
+ const limit = parseExploreLimit(record.limit);
+ if (limit instanceof Response) return limit;
+ const rows = await queryAnalyticsAggregateRows(
+ env,
+ [siteId],
+ toQueryWindow(exploreTimeRange),
+ buildInternalUrl(bodyUrl, exploreTimeRange),
+ request,
+ {
+ dimensions,
+ metrics,
+ complexFilters,
+ limit,
+ orderBy,
+ },
+ );
+ if (rows instanceof Response) return rows;
return jsonSuccess(
{
- rows: [],
- metrics: Array.isArray(record.metrics) ? record.metrics : [],
- dimensions: Array.isArray(record.dimensions) ? record.dimensions : [],
+ rows,
+ metrics,
+ dimensions,
filters: complexFilters,
},
- { request, meta: { timeRange } },
+ { request, meta: { timeRange: exploreTimeRange } },
);
}
if (resource === "retention" && path[4] === "cohorts") {
@@ -1852,16 +2476,65 @@ async function handlePerformance(
url: URL,
principal: ApiKeyPrincipal,
siteId: string,
+ path: string[],
): Promise {
const site = await ensureAnalyticsAccess(request, env, principal, siteId);
if (site instanceof Response) return site;
if (request.method !== "GET") return methodNotAllowed(request);
const timeRange = parseTimeRange(url);
if (timeRange instanceof Response) return timeRange;
- return runLegacyQuery(request, env, siteId, url, "performance", {
- timeRange,
- meta: { timeRange },
- });
+ const filters = parseFilter(url);
+ if (filters instanceof Response) return filters;
+ const window = toQueryWindow(timeRange);
+ const internalUrl = buildInternalUrl(url, timeRange);
+ const resource = path[3] || "summary";
+ if (resource === "summary") {
+ const data = await queryPerformanceSummaryData(
+ env,
+ siteId,
+ window,
+ internalUrl,
+ );
+ return jsonSuccess(data, { request, meta: { timeRange } });
+ }
+ if (resource === "timeseries") {
+ const data = await queryPerformanceTimeseriesData(
+ env,
+ siteId,
+ window,
+ internalUrl,
+ );
+ return jsonList(data.rows, {
+ request,
+ meta: { timeRange, interval: data.interval },
+ });
+ }
+ if (resource === "breakdowns" && path[4]) {
+ const dimension = validateDimension(path[4]);
+ if (dimension instanceof Response) return dimension;
+ const metric = parsePerformanceMetric(url);
+ if (metric instanceof Response) return metric;
+ const rows = await queryPerformanceBreakdownData(
+ env,
+ siteId,
+ window,
+ internalUrl,
+ request,
+ dimension,
+ );
+ if (rows instanceof Response) return rows;
+ return jsonList(rows, {
+ request,
+ meta: { timeRange, dimension, metric },
+ });
+ }
+ return jsonError(
+ "resource_not_found",
+ "Resource not found",
+ 404,
+ undefined,
+ request,
+ );
}
async function handleRealtime(
@@ -2055,7 +2728,7 @@ export async function handleApiV1(
return handleFunnels(request, env, url, principal, siteId, path);
}
if (path[2] === "performance") {
- return handlePerformance(request, env, url, principal, siteId);
+ return handlePerformance(request, env, url, principal, siteId, path);
}
if (path[2] === "realtime") {
return handleRealtime(request, env, url, principal, siteId, path);
From 5dbf87f76488bb7dcdd420a79ba43bb70dfe3a20 Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 21:39:50 +0800
Subject: [PATCH 12/40] fix: relax CSP connect-src and expand eslint
allowDefaultProject
- Simplify CSP connect-src from domain whitelist to `https: wss:` to
unblock caniuse, geo translation, and iconify flag resources
- Add .well-known route files to eslint allowDefaultProject
---
eslint.config.js | 7 ++++++-
next.config.ts | 2 +-
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/eslint.config.js b/eslint.config.js
index 9c0e1584..bcebf5b3 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -110,7 +110,12 @@ export default [
languageOptions: {
parserOptions: {
projectService: {
- allowDefaultProject: ["*.ts"],
+ allowDefaultProject: [
+ "*.ts",
+ "src/app/.well-known/change-password/route.ts",
+ "src/app/.well-known/health/route.ts",
+ "src/app/.well-known/security.txt/route.ts",
+ ],
},
tsconfigRootDir: import.meta.dirname,
},
diff --git a/next.config.ts b/next.config.ts
index 89737466..aac68f99 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -57,7 +57,7 @@ const nextConfig: NextConfig = {
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
- "connect-src 'self' https://cdn.jsdelivr.net https://*.tiles.mapbox.com https://api.mapbox.com https://events.mapbox.com https://insight.ravelloh.com wss://*.insight.ravelloh.com",
+ "connect-src 'self' https: wss:",
"frame-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
From 30b7830514abb090766ab8923942c0293f68c6bf Mon Sep 17 00:00:00 2001
From: RavelloH <68409330+RavelloH@users.noreply.github.com>
Date: Sat, 27 Jun 2026 21:43:54 +0800
Subject: [PATCH 13/40] fix: preserve current analytics tab when clicking site
in sidebar
---
src/components/dashboard/dashboard-shell.tsx | 22 +++++++++++++
.../dashboard/sidebar-site-details.tsx | 32 +++++++++++++++++--
2 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/src/components/dashboard/dashboard-shell.tsx b/src/components/dashboard/dashboard-shell.tsx
index 6d1a67ab..cc793fca 100644
--- a/src/components/dashboard/dashboard-shell.tsx
+++ b/src/components/dashboard/dashboard-shell.tsx
@@ -326,6 +326,27 @@ export function DashboardShell({
const mainLayoutSegments = visibleLayoutSegments(useSelectedLayoutSegments());
const mainSiteSection = mainLayoutSegments[1] || "";
const mainSiteSubSection = mainLayoutSegments[2] || "";
+ const validAnalyticsSections = [
+ "realtime",
+ "pages",
+ "referrers",
+ "sessions",
+ "events",
+ "visitors",
+ "geo",
+ "devices",
+ "browsers",
+ "performance",
+ "settings",
+ "campaigns",
+ "funnels",
+ "retention",
+ ] as const;
+ const currentAnalyticsSection = validAnalyticsSections.includes(
+ mainSiteSection as (typeof validAnalyticsSections)[number],
+ )
+ ? (mainSiteSection as (typeof validAnalyticsSections)[number])
+ : undefined;
const routeState = parseSidebarRouteState(livePathname, activeTeamSlug);
const hasManagementSections = Boolean(
managementSections && managementSections.length > 0,
@@ -575,6 +596,7 @@ export function DashboardShell({
teamId={activeTeamId}
teamSlug={activeTeamSlug}
activeSiteSlug={resolvedActiveSiteSlug}
+ currentSection={currentAnalyticsSection}
sites={sites.map((site) => ({
id: site.id,
slug: site.slug,
diff --git a/src/components/dashboard/sidebar-site-details.tsx b/src/components/dashboard/sidebar-site-details.tsx
index 10cb442e..463f9222 100644
--- a/src/components/dashboard/sidebar-site-details.tsx
+++ b/src/components/dashboard/sidebar-site-details.tsx
@@ -60,11 +60,28 @@ interface SidebarSiteSummary {
iconPath?: string;
}
+type AnalyticsSection =
+ | "realtime"
+ | "pages"
+ | "referrers"
+ | "sessions"
+ | "events"
+ | "visitors"
+ | "geo"
+ | "devices"
+ | "browsers"
+ | "performance"
+ | "settings"
+ | "campaigns"
+ | "funnels"
+ | "retention";
+
interface SidebarSiteDetailsProps {
locale: Locale;
teamId: string;
teamSlug: string;
activeSiteSlug?: string;
+ currentSection?: AnalyticsSection;
sites: SidebarSiteSummary[];
labels: {
views: string;
@@ -88,8 +105,11 @@ function buildSitePath(
locale: Locale,
teamSlug: string,
siteSlug: string,
+ section?: AnalyticsSection,
): string {
- return `/${locale}/app/${teamSlug}/${siteSlug}`;
+ const base = `/${locale}/app/${teamSlug}/${siteSlug}`;
+ if (!section) return base;
+ return `${base}/${section}`;
}
function safeCount(value: number): number {
@@ -201,6 +221,7 @@ export function SidebarSiteDetails({
teamId,
teamSlug,
activeSiteSlug,
+ currentSection,
sites,
labels,
messages,
@@ -408,7 +429,14 @@ export function SidebarSiteDetails({
tooltip={site.name}
className="h-8 rounded-none"
>
-
+
Date: Sat, 27 Jun 2026 22:49:00 +0800
Subject: [PATCH 14/40] chore: new icon with more beautiful views
---
public/android-chrome-192x192.png | Bin 34091 -> 21459 bytes
public/android-chrome-512x512.png | Bin 210262 -> 100111 bytes
public/favicon-16x16.png | Bin 470 -> 667 bytes
public/favicon-32x32.png | Bin 1192 -> 1759 bytes
public/favicon.ico | Bin 15406 -> 56045 bytes
public/icon-1024.png | Bin 0 -> 101618 bytes
6 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 public/icon-1024.png
diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png
index 832015be8c53100a18b39e472aa0945f3f9a4584..a9bafb61abc978b28d671449946b9dd1df662d9c 100644
GIT binary patch
literal 21459
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