Eloquent

Documentation

<!-- AUTO-GENERATED from packages/@elqnt/analytics/SKILL.md — do not edit here. Run `pnpm docs:sync`. -->
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 the useAnalyticsContext alias 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/analyticsdist/**/*.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:

  1. import { useProductAnalytics } from "@elqnt/analytics/hooks" — the one hook (useAnalyticsContext is the same function under an event-tracking name).
  2. 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.
  3. import type { … } from "@elqnt/analytics/models"AnalyticsSummary, ChatAnalytics, DateFilter, etc. (the DTOs).
  4. 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):

  1. 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.
  2. Let the compiler check you. Build with tsc; never as any / @ts-ignore your way around the hook's types to force a call that isn't there.
  3. Never bypass the package. No fetch/axios to /api/v1/analytics/..., no NATS, no direct service calls. (Server-side: mint a token with createServerClient from @elqnt/api-client/server and call the same gateway paths.)
  4. Wrap, don't scatter. Import the hook in exactly one app file (hooks/use-<domain>.ts or a context provider); see "The app layer".
  5. 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 product you send (JWT claim server-side, X-Product header browser-side) selects which analytics_<product> DB the request reads (empty → eloquent). See skill 31-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 → browserApiRequestgetGatewayToken() 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-Product from the signed JWT claims before proxying, so a forged header can't override the token — the JWT is authoritative. The global/* analytics routes require an admin/super_admin role (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 via createServerClient directly — not the hook.

Env vars

VarUsed byPurpose
JWT_SECRET/api/gateway-token route + createServerClientsign the gateway JWT (same value the gateway validates with)
API_GATEWAY_URL_INTERNALcreateServerClient gatewayUrlin-cluster gateway URL (server)
API_GATEWAY_URL_PUBLICSSR layout → AppConfigProvider → browser baseUrlpublic gateway URL, read at request time (not NEXT_PUBLIC_*)
ORG_IDtoken route / app configthe org all requests are scoped to

Headers the api layer sets per request

From hook optionHeader
auto tokenAuthorization: Bearer <token>
orgIdX-Org-ID
userIdX-User-ID
userEmailX-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 aggregate loading / error flags. On failure a query method returns its default (null or []) and sets error — it does not throw.

Callback identity. Hook methods are recreated when options change (the deps array is [options]). If you need a stable ref inside useEffect/ useCallback, stash it in a useRef.


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;
}
MethodSignatureResolves toEndpoint
getSummary(filter?: DateFilter) => Promise<AnalyticsSummary | null>null; caches summaryGET /api/v1/analytics/summary (?from&to)
getChats(filter?: DateFilter, agentId?: string) => Promise<ChatAnalytics[]>[]; caches chatsGET /api/v1/analytics/chats (?from&to&agent_id)
getAgents(filter?: DateFilter) => Promise<AgentAnalytics[]>[]; caches agentsGET /api/v1/analytics/agents (?from&to)
getUsage(filter?: DateFilter) => Promise<UsageAnalytics[]>[]; caches usageGET /api/v1/analytics/usage (?from&to)
getDaily(filter?: DateFilter) => Promise<DailyAnalytics[]>[]; caches dailyGET /api/v1/analytics/daily (?from&to)
getEvents(filter?: DateFilter) => Promise<AnalyticsEvent[]>[]; caches eventsGET /api/v1/analytics/events (?from&to)
trackEvent(eventType: string, eventCategory: string, properties?: Record<string, unknown>) => Promise<void>void; never throwsPOST /api/v1/analytics/events
trackPageView(pageName?: string) => Promise<void>voidwraps trackEvent("page_view","navigation")
getGlobalSummary(filter?: DateFilter) => Promise<GlobalAnalyticsSummary | null>nullGET /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/trackPageView are fire-and-forget: they swallow errors so a failed beacon never disrupts the user. A per-tab sessionId is generated and stored in sessionStorage automatically, and options.userId is stamped on the event. The global/* 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).

FnEndpoint
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 (+ the useAnalyticsContext alias) is "use client". For SSR/server actions, call the gateway paths via createServerClient (or the api functions), not the hook.
  • Methods don't throw. Query methods return defaults (null, []) and set error; trackEvent/trackPageView swallow 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 (inject baseUrl/orgId once via SSR) and a per-domain context.
  • product selects the DB. The gateway resolves product from the JWT claim (server) and X-Product (browser); mismatched product = wrong analytics_<product> DB. See skill 31-analytics-clickhouse.
  • Global analytics is admin-only. getGlobalSummary / getGlobalOrgs (and the global/* 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.