npm install @elqnt/analytics
Analytics — Building a Product-Analytics Frontend
Eloquent's analytics surface gives a custom app per-org dashboards (chats,
agents, token/cost usage, daily trends, an events log), platform-admin global
roll-ups, and a fire-and-forget frontend event tracker. This skill is only
about one thing: driving it through the @elqnt/analytics package.
Package is
@elqnt/analytics. It exports one React hook (useProductAnalytics, plus theuseAnalyticsContextalias of the same hook) on top of a set of api functions and DTOs. The hook is the primary interface; the api functions are the building blocks it wraps.
⛔ The contract — read this before writing any code
This skill ships inside the package, so the contract is not a separate file
to keep in sync — it is the package's own published type declarations
(@elqnt/analytics → dist/**/*.d.ts). Because your app imports the real
hook, the TypeScript compiler enforces the contract for you: a wrong param or a
misused return value won't compile.
The single source of truth, in order:
import { useProductAnalytics } from "@elqnt/analytics/hooks"— the one hook (useAnalyticsContextis the same function under an event-tracking name).import type { UseProductAnalyticsReturn, ProductAnalyticsState } from "@elqnt/analytics/hooks"— the named, exact method surface of the hook (every method, its params, its return type) and the cached-state shape. The tables below are a human-readable mirror of these.import type { … } from "@elqnt/analytics/models"—AnalyticsSummary,ChatAnalytics,DateFilter, etc. (the DTOs).import { … } from "@elqnt/analytics/api"— the lower-level api functions the hook wraps, for SSR/server-side or a flow no hook covers.
Rules (do not drift):
- Use only the exported hook and only the methods it exposes. Do not
invent hooks, methods, params, or return shapes — if it's not on
UseProductAnalyticsReturn, it doesn't exist. - Let the compiler check you. Build with
tsc; neveras any/@ts-ignoreyour way around the hook's types to force a call that isn't there. - Never bypass the package. No
fetch/axiosto/api/v1/analytics/..., no NATS, no direct service calls. (Server-side: mint a token withcreateServerClientfrom@elqnt/api-client/serverand call the same gateway paths.) - Wrap, don't scatter. Import the hook in exactly one app file
(
hooks/use-<domain>.tsor a context provider); see "The app layer". - If a requirement seems to need a call the package doesn't define, stop and flag it — do not improvise an endpoint.
The prose/tables below explain each method; the package's shipped .d.ts is
what your code type-checks against.
Architecture — the one and only path
A component never calls the package hook directly. It only sees your domain
context (the dashboard state), served by a context provider, backed by your
app hook, which is the only place that wraps useProductAnalytics.
Everything below the app hook is plumbing the component never imports.
┌──────────────────────────────────────────────────────────────────────────────┐
│ YOUR CUSTOM APP (Next.js / React) │
│ │
│ SSR layout: read API_GATEWAY_URL_PUBLIC (request-time) + ORG_ID │
│ └─► AppConfigProvider { apiGatewayUrl, orgId } │
│ │
│ components/analytics/* ── speak only the dashboard view-model │
│ │ useAnalyticsContext() │
│ ▼ │
│ contexts/analytics-context.tsx ── one shared app-hook instance │
│ │ │
│ ▼ │
│ hooks/use-analytics.ts ── app hook: query + track + state │
│ │ │
│ ▼ │
│ useProductAnalytics (@elqnt/analytics/hooks) │
│ │ │
│ ▼ │
│ @elqnt/analytics/api → browserApiRequest │
│ │ getGatewayToken() ⇒ GET /api/gateway-token (mint HS256 JWT, JWT_SECRET)│
│ │ attaches: Authorization: Bearer <jwt>, X-Org-ID, X-User-ID, X-Product│
└─────────┼──────────────────────────────────────────────────────────────────────┘
▼
┌─────────────────┐ verify JWT, stamp X-Org-ID/X-Product, route match
│ API GATEWAY │ /api/v1/analytics/** → analytics backend
└───────┬─────────┘
▼
┌─────────────────┐ per-product analytics DB analytics_<product> (row-level,
│ analytics (Go) │ every row carries org_id; queries scoped WHERE org_id = ?)
└─────────────────┘
Rules: the frontend never calls the analytics service directly and never
touches NATS. Every call is HTTP through the gateway carrying an org id + gateway
token. And components never import @elqnt/analytics — only the app hook does.
Product routing. The
productyou send (JWT claim server-side,X-Productheader browser-side) selects whichanalytics_<product>DB the request reads (empty →eloquent). See skill31-analytics-clickhouse.
The gateway token (the secret) — how the app gets it
Every request to the gateway needs Authorization: Bearer <token>. The token is
a short-lived HS256 JWT signed with the shared gateway secret. You never
hardcode it; there are two flows depending on where the code runs.
Browser flow (what the hook uses)
The hook → api fns → browserApiRequest → getGatewayToken() internally. You
do not pass a token to the hook. getGatewayToken() (from
@elqnt/api-client/browser) by default does:
fetch("/api/gateway-token") ⇒ { token, expiresIn }
So your app must expose a /api/gateway-token route that mints the JWT
server-side (the secret stays on the server, never reaches the browser). The
client caches the token and refreshes ~5 min before expiry.
// app/api/gateway-token/route.ts (Next.js route handler — server only)
import { NextResponse } from "next/server";
import * as jose from "jose";
export async function GET() {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!); // SAME secret the gateway validates with
const token = await new jose.SignJWT({
org_id: process.env.ORG_ID!,
user_id: "system",
email: "system@my-app.com",
role: "admin", // global/* routes require admin/super_admin
scopes: ["read", "write", "admin"], // OR-matched against the route's required scopes
product: "my-product", // gateway resolves product from THIS claim first
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setIssuer("eloquent-gateway") // must match gateway JWT_ISSUER
.setAudience("eloquent-api") // must match gateway JWT_AUDIENCE
.setExpirationTime("1h")
.sign(secret);
return NextResponse.json({ token, expiresIn: 3600 });
}
The gateway re-stamps
X-Org-ID/X-User-ID/X-Productfrom the signed JWT claims before proxying, so a forged header can't override the token — the JWT is authoritative. Theglobal/*analytics routes require anadmin/super_adminrole (or a*scope) — mint the token accordingly.
Override the token source (non-default URL, native app) once at startup:
import { configureAuth } from "@elqnt/api-client/browser";
configureAuth(async () => myTokenProvider()); // URL string or async () => token|null
// clearGatewayTokenCache() — call after switching orgs
Server flow (server actions / SSR)
@elqnt/api-client/server mints the JWT itself with jose — no
/api/gateway-token hop. This is where the secret is injected directly:
import { createServerClient } from "@elqnt/api-client/server";
const client = createServerClient({
gatewayUrl: process.env.API_GATEWAY_URL_INTERNAL!, // in-cluster gateway URL (server-side)
jwtSecret: process.env.JWT_SECRET!, // the shared gateway secret
defaultProduct: "my-product", // gateway reads product from the JWT claim first
defaultScopes: ["read", "write", "admin"],
});
await client.get("/api/v1/analytics/summary?from=2024-01-01&to=2024-12-31", { orgId, userId });
The analytics hook is browser-only (
"use client"). For SSR/server actions call the gateway paths viacreateServerClientdirectly — not the hook.
Env vars
| Var | Used by | Purpose |
|---|---|---|
JWT_SECRET | /api/gateway-token route + createServerClient | sign the gateway JWT (same value the gateway validates with) |
API_GATEWAY_URL_INTERNAL | createServerClient gatewayUrl | in-cluster gateway URL (server) |
API_GATEWAY_URL_PUBLIC | SSR layout → AppConfigProvider → browser baseUrl | public gateway URL, read at request time (not NEXT_PUBLIC_*) |
ORG_ID | token route / app config | the org all requests are scoped to |
Headers the api layer sets per request
| From hook option | Header |
|---|---|
| auto token | Authorization: Bearer <token> |
orgId | X-Org-ID |
userId | X-User-ID |
userEmail | X-User-Email |
product (default "eloquent") | X-Product |
Hook options
There is no provider/context baked into the package. You pass options into
the hook call. The hook's options is just ApiClientOptions:
interface ApiClientOptions {
baseUrl: string; // API Gateway base URL — required
orgId: string; // required → X-Org-ID
userId?: string; // → X-User-ID (also stamped on tracked events)
userEmail?: string; // → X-User-Email
product?: string; // → X-Product, defaults to "eloquent"; selects analytics_<product> DB
headers?: Record<string, string>;
}
type UseProductAnalyticsOptions = ApiClientOptions;
Imperative, not auto-fetching. Every method returns a Promise you
await. The hook also caches the latest result of each query on its state (summary,chats,agents,usage,daily,events) and exposes aggregateloading/errorflags. On failure a query method returns its default (nullor[]) and setserror— it does not throw.Callback identity. Hook methods are recreated when
optionschange (the deps array is[options]). If you need a stable ref insideuseEffect/useCallback, stash it in auseRef.
Hook: useProductAnalytics — analytics + event tracking
import { useProductAnalytics } from "@elqnt/analytics/hooks";
const a = useProductAnalytics({ baseUrl, orgId, userId, userEmail });
useAnalyticsContext is an exported alias of this hook (same shape), named
for the event-tracking use case.
Returns UseProductAnalyticsReturn = the spread state (latest cached results
loading/error) plus methods. State shape (ProductAnalyticsState):
interface ProductAnalyticsState {
summary: AnalyticsSummary | null;
chats: ChatAnalytics[];
agents: AgentAnalytics[];
usage: UsageAnalytics[];
daily: DailyAnalytics[];
events: AnalyticsEvent[];
loading: boolean;
error: string | null;
}
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
getSummary | (filter?: DateFilter) => Promise<AnalyticsSummary | null> | null; caches summary | GET /api/v1/analytics/summary (?from&to) |
getChats | (filter?: DateFilter, agentId?: string) => Promise<ChatAnalytics[]> | []; caches chats | GET /api/v1/analytics/chats (?from&to&agent_id) |
getAgents | (filter?: DateFilter) => Promise<AgentAnalytics[]> | []; caches agents | GET /api/v1/analytics/agents (?from&to) |
getUsage | (filter?: DateFilter) => Promise<UsageAnalytics[]> | []; caches usage | GET /api/v1/analytics/usage (?from&to) |
getDaily | (filter?: DateFilter) => Promise<DailyAnalytics[]> | []; caches daily | GET /api/v1/analytics/daily (?from&to) |
getEvents | (filter?: DateFilter) => Promise<AnalyticsEvent[]> | []; caches events | GET /api/v1/analytics/events (?from&to) |
trackEvent | (eventType: string, eventCategory: string, properties?: Record<string, unknown>) => Promise<void> | void; never throws | POST /api/v1/analytics/events |
trackPageView | (pageName?: string) => Promise<void> | void | wraps trackEvent("page_view","navigation") |
getGlobalSummary | (filter?: DateFilter) => Promise<GlobalAnalyticsSummary | null> | null | GET /api/v1/analytics/global/summary (?from&to, admin) |
getGlobalOrgs | (filter?: DateFilter) => Promise<OrgAnalytics[]> | [] | GET /api/v1/analytics/global/orgs (?from&to, admin) |
interface DateFilter { from?: string; to?: string; } // ISO 8601 → ?from&to
trackEvent/trackPageVieware fire-and-forget: they swallow errors so a failed beacon never disrupts the user. A per-tabsessionIdis generated and stored insessionStorageautomatically, andoptions.userIdis stamped on the event. Theglobal/*endpoints are platform-admin only.
Core types (@elqnt/analytics/models)
Analytics DTOs are hand-written (
models/analytics.ts); wire fields are snake_case to match the Go response shapes.
interface AnalyticsSummary { total_chats: number; total_messages: number; total_tokens: number; total_cost: number; unique_users: number; avg_session_duration: number; }
interface ChatAnalytics { chat_id: string; agent_id: string; user_id: string; message_count: number; total_tokens: number; duration_secs: number; csat_score?: number; created_at: string; }
interface AgentAnalytics { agent_id: string; agent_name?: string; total_chats: number; total_messages: number; total_tokens: number; avg_duration_secs: number; unique_users: number; avg_csat?: number; }
interface UsageAnalytics { service: string; model: string; input_tokens: number; output_tokens: number; total_tokens: number; cost: number; request_count: number; }
interface DailyAnalytics { date: string; total_chats: number; total_messages: number; total_tokens: number; unique_users: number; }
interface AnalyticsEvent { eventId?: string; orgId?: string; sessionId: string; userId: string; eventType: string; eventCategory: string; pagePath?: string; properties?: Record<string, unknown>; timestamp?: string; }
interface GlobalAnalyticsSummary { total_orgs: number; total_chats: number; total_messages: number; total_tokens: number; total_cost: number; total_users: number; }
interface OrgAnalytics { org_id: string; org_name: string; total_chats: number; total_messages: number; total_tokens: number; total_cost: number; unique_users: number; }
Per-channel DTOs (platform-admin, channel-parameterized over the shared tables):
interface GlobalChannelSummary { channel: string; total_orgs: number; total_chats: number; total_messages: number; inbound_messages: number; total_users: number; total_tokens: number; total_cost: number; avg_messages_per_chat: number; }
interface ChannelDaily { date: string; total_chats: number; total_messages: number; unique_users: number; }
interface OrgChannelAnalytics { org_id: string; org_name: string; total_chats: number; total_messages: number; unique_users: number; total_tokens: number; total_cost: number; }
Each DTO also has a matching *Response wrapper (SummaryResponse,
ChatsResponse, …) carrying { success, data, metadata? } — that's what the
api functions return inside ApiResponse<T>.
API functions (@elqnt/analytics/api) — for SSR / no-hook flows
The hook covers all the query/track flows for the browser. For SSR or server
actions, call the api functions directly (they wrap browserApiRequest); all
take a DateFilter | undefined (where relevant) and an ApiClientOptions, and
return ApiResponse<T> ({ data?, error?, status } — check error).
| Fn | Endpoint |
|---|---|
getAnalyticsSummaryApi(filter, options) | GET /api/v1/analytics/summary |
getChatsAnalyticsApi(filter, agentId, options) | GET /api/v1/analytics/chats |
getAgentsAnalyticsApi(filter, options) | GET /api/v1/analytics/agents |
getUsageAnalyticsApi(filter, options) | GET /api/v1/analytics/usage |
getDailyAnalyticsApi(filter, options) | GET /api/v1/analytics/daily |
getAnalyticsEventsApi(filter, options) | GET /api/v1/analytics/events |
logAnalyticsEventApi(event, options) | POST /api/v1/analytics/events |
getGlobalSummaryApi(filter, options) | GET /api/v1/analytics/global/summary (admin) |
getGlobalOrgsAnalyticsApi(filter, options) | GET /api/v1/analytics/global/orgs (admin) |
The app layer
Wrap the hook in one app file and a context, so components speak the
dashboard view-model, not the gateway. Read baseUrl/orgId from SSR (an
AppConfig context fed by API_GATEWAY_URL_PUBLIC at request time), never a
NEXT_PUBLIC_* var.
// contexts/analytics-context.tsx — one shared hook instance for the subtree
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { useProductAnalytics, type UseProductAnalyticsReturn } from "@elqnt/analytics/hooks";
import { useAppConfig } from "@/contexts/app-config-context";
const Ctx = createContext<UseProductAnalyticsReturn | undefined>(undefined);
export function AnalyticsProvider({ children, orgId }: { children: ReactNode; orgId: string }) {
const { apiGatewayUrl } = useAppConfig();
const value = useProductAnalytics({ baseUrl: apiGatewayUrl, orgId }); // single instance
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useAnalyticsContext(): UseProductAnalyticsReturn {
const c = useContext(Ctx);
if (!c) throw new Error("useAnalyticsContext must be used within AnalyticsProvider");
return c;
}
// components/analytics/summary-stats.tsx — consume the context, never import @elqnt/analytics
"use client";
import { useEffect } from "react";
import { useAnalyticsContext } from "@/contexts/analytics-context";
export function SummaryStats() {
const { summary, getSummary, loading, error } = useAnalyticsContext();
useEffect(() => { getSummary({ from: "2024-01-01", to: "2024-12-31" }); }, [getSummary]);
if (error) return <p>{error}</p>;
if (!summary) return <p>Loading…</p>;
return <p>{summary.total_chats} chats · {summary.unique_users} users</p>;
}
Rule of thumb: @elqnt/analytics is imported in exactly the context/app-hook
file. Everything above it speaks the dashboard view-model. The typed
UseProductAnalyticsReturn import is how the context carries the hook shape.
Gotchas
- One hook, browser-only.
useProductAnalytics(+ theuseAnalyticsContextalias) is"use client". For SSR/server actions, call the gateway paths viacreateServerClient(or the api functions), not the hook. - Methods don't throw. Query methods return defaults (
null,[]) and seterror;trackEvent/trackPageViewswallow errors entirely. Don't wrap in try/catch expecting throws. - The hook caches each query's latest result on its state. Read
summary,chats, … off the return value, or use what the method resolves to — both are in sync. - No provider in the package. Options go into the hook call — which is why
your app builds
AppConfigProvider(injectbaseUrl/orgIdonce via SSR) and a per-domain context. productselects the DB. The gateway resolves product from the JWT claim (server) andX-Product(browser); mismatched product = wronganalytics_<product>DB. See skill31-analytics-clickhouse.- Global analytics is admin-only.
getGlobalSummary/getGlobalOrgs(and theglobal/*endpoints) require an admin/super_admin token. - Wire fields are snake_case. The DTOs mirror the Go response shapes
(
total_chats,unique_users, …) — don't camelCase them.