Last updated

/iblai-vibe-analytics

Mirrored from iblai/vibe · skills/analytics/iblai-vibe-analytics/SKILL.md. This page is generated — edit it in the repository, not here.

First time here? If iblai.env has no ARCHITECTURE=, run /iblai-vibe-start first (four questions; two minutes) — it decides single-org / multi-org / headless and who signs in, and every skill reads the answer.

Scope: per organization, optionally narrowed to one agent and to user groups. One AnalyticsLayout renders the tab strip (Overview · Users · Courses · Programs · Topics · Transcripts · Memory · Cost · Audit, with Data Reports on the right), plus a single control bar: agent picker, groups filter, and a Today / 7D / 30D / 90D / Custom range that every chart follows. Each tab is a Next.js route under basePath.

Analytics — Overview
Analytics — Overview

Template: the Overview page is bundled as assets/analytics-page.tsx.j2. See /iblai-vibe-scaffold for the {{ }} contract.

Do NOT add custom styles to ibl.ai SDK components. Do NOT implement dark mode unless asked. Follow the component hierarchy: ibl.ai SDK (@iblai/iblai-js) first, then shadcn/ui (npx shadcn@latest add).

Prerequisites

  • Auth set up (/iblai-vibe-auth)
  • MCP server + skills configured (@iblai/mcp in .mcp.json)
  • @iblai/iblai-js ≥ 2.12 resolving @iblai/web-containers ≥ 1.21 (the analytics revamp). Check with pnpm why @iblai/web-containers. Older versions have no Memory tab, agent picker, control bar or Cost sub-tabs, and the code below will not typecheck.
  • iblai.env populated with PLATFORM, DOMAIN, TOKEN. If missing: curl -o iblai.env https://raw.githubusercontent.com/iblai/vibe/refs/heads/main/iblai.env
  • API companion (recommended): npx skills add iblai/vibe --all. Installs the /iblai-api-analytics REST reference and a filtered snapshot of the live OpenAPI schema. This skill (frontend wiring) and that one (API contract) stay in lockstep.

On vibe-starter? The dashboard already lives at /admin/analytics (one AnalyticsPage switch in components/admin/analytics-page.tsx; ?agent=<uuid> scopes it). Two things to fix:

  1. Its pnpm-workspace.yaml pins @iblai/web-containers to 1.16.0 (an old workaround). That pin predates the revamp, so delete the overrides: entry (data-layer ≥ 1.14.1 ships the exports it worked around), then run pnpm install.
  2. It only has Overview, Users, Topics, Transcripts, Financial and Reports routes, but the SDK strip also lists Courses, Programs, Audit and Memory, so those tabs 404. Add the memory and audit routes below, and pass excludeTabs={["courses", "programs"]} to its layout.

Read /analytics as /admin/analytics throughout.

Layout

app/(app)/analytics/layout.tsx owns everything shared: tabs, picker, groups filter, date range, and the tab guard for watchers. The SDK layout paints bg-[#f5f7fb], so wrap it in a white card.

"use client";

import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
  AnalyticsLayout,
  AnalyticsSettingsProvider,
  GroupsFilterDropdown,
  type GroupOption,
} from "@iblai/iblai-js/web-containers";
import {
  useGetMemsearchStatusQuery,
  usePlatformUserGroupsQuery,
} from "@iblai/iblai-js/data-layer";
import { getUserName } from "@iblai/iblai-js/web-utils";
import { resolveAppTenant } from "@/lib/iblai/tenant";

const basePath = "/analytics";
// Course/program analytics need the ibl.ai catalog — drop them in agent-only apps.
const CATALOG_TABS = ["courses", "programs"];

export default function AnalyticsLayoutWrapper({ children }: { children: React.ReactNode }) {
  const pathname = usePathname() ?? basePath;
  const router = useRouter();
  const [tenantKey, setTenantKey] = useState("");
  const [userId, setUserId] = useState("");
  const [groupIds, setGroupIds] = useState<number[]>([]);

  useEffect(() => {
    setTenantKey(resolveAppTenant());
    setUserId(getUserName() ?? "");
  }, []);

  // Groups the viewer may filter by; Data Reports checks a different action.
  const { data: groupsPage, isLoading: groupsLoading } = usePlatformUserGroupsQuery(
    {
      platformKey: tenantKey,
      requiredAction: pathname.startsWith(`${basePath}/reports`)
        ? "Ibl.Analytics/Reports/read"
        : "Ibl.Analytics/Core/read",
    },
    { skip: !tenantKey },
  );
  const groups = useMemo<GroupOption[]>(
    () =>
      (groupsPage?.results ?? []).flatMap((g: { id: number; name?: string | null }) =>
        g.name ? [{ id: g.id, name: g.name }] : [],
      ),
    [groupsPage],
  );

  // Hide Memory when memsearch is off for the org.
  const { data: memsearch } = useGetMemsearchStatusQuery(
    { org: tenantKey, userId },
    { skip: !tenantKey || !userId },
  );
  const excludeTabs =
    memsearch?.enable_memsearch === false ? [...CATALOG_TABS, "memory"] : CATALOG_TABS;

  const settings = useMemo(
    () => ({ usergroupIds: groupIds.length ? groupIds : undefined }),
    [groupIds],
  );

  return (
    <div className="mx-auto w-full flex-1 overflow-auto px-4 py-8 md:w-[75vw] md:px-0">
      <div className="overflow-hidden rounded-lg border border-[var(--border-color)] bg-white">
        <AnalyticsSettingsProvider value={settings}>
          <AnalyticsLayout
            currentPath={pathname}
            basePath={basePath}
            onTabChange={(tab) => router.push(tab ? `${basePath}/${tab}` : basePath)}
            excludeTabs={excludeTabs}
            showPicker
            tenantKey={tenantKey}
            beforeDataReports={
              <GroupsFilterDropdown
                groups={groups}
                selectedGroupIds={groupIds}
                onSelectionChange={setGroupIds}
                isLoading={groupsLoading}
                placeholder="Filter by Groups"
              />
            }
          >
            {children}
          </AnalyticsLayout>
        </AnalyticsSettingsProvider>
      </div>
    </div>
  );
}

What the layout does for you:

  • Date range: one shared range (default 30D). Cards follow it; a card changed on its own shows an override chip. Don't add per-page date pickers.
  • Agent picker (showPicker): "All Agents" or one agent, kept in layout state (it resets on reload). To keep it in the URL, control it with scopedMentor + onScopedMentorChange.
  • Groups filter: beforeDataReports renders into the control bar; the selection reaches pages through AnalyticsSettingsProvider. Pages must pass it on as usergroupIds — the tabs don't read the context.
  • Watchers (non-admins holding /watchedgroups/#list) only see Courses, Programs, Memory, Transcripts and Data Reports; a deep link to any other tab redirects to the first allowed tab that isn't in excludeTabs.

Pages

Render assets/analytics-page.tsx.j2 into app/(app)/analytics/page.tsx (Overview). Every other tab is a small client page. Tabs that take the groups filter (Users, Topics, Transcripts) follow this shape, as in app/(app)/analytics/users/page.tsx:

"use client";

import { useEffect, useState } from "react";
import { AnalyticsUsersStats, useAnalyticsSettings } from "@iblai/iblai-js/web-containers";
import { resolveAppTenant } from "@/lib/iblai/tenant";

export default function UsersPage() {
  const { usergroupIds } = useAnalyticsSettings();
  const [tenantKey, setTenantKey] = useState("");
  useEffect(() => setTenantKey(resolveAppTenant()), []);
  if (!tenantKey) return null;
  return <AnalyticsUsersStats tenantKey={tenantKey} mentorId="" usergroupIds={usergroupIds} />;
}

Memory, Cost and Audit also need the signed-in username (userId) — their endpoints are /orgs/{org}/users/{username}/… paths. For example, app/(app)/analytics/financial/page.tsx:

"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AnalyticsFinancialStats } from "@iblai/iblai-js/web-containers";
import { getUserName } from "@iblai/iblai-js/web-utils";
import { resolveAppTenant } from "@/lib/iblai/tenant";

export default function CostPage() {
  const router = useRouter();
  const [tenantKey, setTenantKey] = useState("");
  const [userId, setUserId] = useState("");
  useEffect(() => {
    setTenantKey(resolveAppTenant());
    setUserId(getUserName() ?? "");
  }, []);
  if (!tenantKey || !userId) return null;
  return (
    <AnalyticsFinancialStats
      tenantKey={tenantKey}
      mentorId=""
      userId={userId}
      // The trace panel links a session to its transcript; the Transcripts tab reads ?session_id=.
      onOpenTranscript={(sessionId) =>
        router.push(`/analytics/transcripts?session_id=${encodeURIComponent(sessionId)}`)
      }
    />
  );
}

memory/page.tsx and audit/page.tsx have the same shape and render:

<AnalyticsMemoryStats tenantKey={tenantKey} mentorId="" userId={userId} />
<AnalyticsAuditLogStats tenantKey={tenantKey} mentorId="" userId={userId} />
RouteComponentProps beyond tenantKey + mentorId=""
/analyticsAnalyticsOverviewusergroupIds, onViewAllTopics (adds "All topics →")
/analytics/usersAnalyticsUsersStatsusergroupIds
/analytics/coursesAnalyticsCoursesbasePath="/analytics" → opens courses/[courseId]
/analytics/courses/[courseId]AnalyticsCourseDetailcourseId (from useParams), onBack
/analytics/programsAnalyticsProgramsbasePath="/analytics" → opens programs/[programId]
/analytics/programs/[programId]AnalyticsProgramDetailprogramId, onBack
/analytics/topicsAnalyticsTopicsStatsusergroupIds
/analytics/transcriptsAnalyticsTranscriptsStatsusergroupIds, currentSPA, turnDetails (slots); reads ?session_id=
/analytics/memoryAnalyticsMemoryStatsuserId (required), myMemory (own memories only)
/analytics/financial (label Cost)AnalyticsFinancialStatsuserId, onOpenTranscript. No basePath; usergroupIds is deprecated and ignored
/analytics/auditAnalyticsAuditLogStatsuserId (required), defaultScope, timezone, canViewPlatformAudit
/analytics/reportsAnalyticsReportsno mentorId: selectedMentorId="" (required), usergroupIds, disabledReports

Courses/Programs pages only matter if you removed them from CATALOG_TABS. Use MCP for the full props: get_component_info("AnalyticsMemoryStats"), etc.

Agent scope rule. Every tab resolves its agent as selectedMentorId || mentorId || picker. On org-wide pages pass mentorId="" and leave selectedMentorId unset, or the picker is silently ignored. "" means org-wide; never pass undefined for mentorId.

What each tab shows

TabWhat's in itBacked by
OverviewMessages, Active Users, Topics, Conversations, LLM spend KPIs with sparklines; Sessions chart; top Topicschat analytics + llm-usage (spend card)
UsersLogged in now / past 30 days / registered; Active Users; Access Times heatmap; User Details table/api/analytics/users/…, time/
TopicsTopics / Conversations / Messages KPIs; Conversations over time; Topics Details/api/analytics/topics/…, conversations/
TranscriptsAvg messages / cost / rating; conversation list + transcript panel with attachments, retrieved documents, tool calls and per-turn details (model, temperature, request context)/api/analytics/messages/…, messages/details/
MemoryMemories per user; scope Agent / All agents / Global, agent search, user, content and date filters/api/ai-mentor/orgs/{org}/users/{username}/…memories…/
Cost → SpendWeekly / Monthly / Total costs, Cost per Day, Cost by Provider / LLM, Cost per User (→ Traces)ClickHouse: /api/ai-analytics/orgs/{org}/users/{username}/{tenant-cost, agents/{id}/cost, costs/model, costs/peruser}/
Cost → Usage & latencyLLM spend, Tokens, LLM calls, Latency p95; spend over time by service; spend and p50/p95 latency by model/api/analytics/llm-usage/?resource=metrics
Cost → TracesPer-call list (service, user, latency, cost) with a detail panel of observationsllm-usage resource=traces / observations
Audit → AgentAgent configuration changes (who, what, when); user, date and action filters/api/ai-mentor/orgs/{org}/users/{username}/mentors/audit-logs/
Audit → PlatformOrg-wide changes and security events (logins, model changes), with IP and a detail sheet/api/core/platforms/{org}/audit-logs/
Data ReportsAsync CSV/JSON exports (generate → poll → download)/api/reports/platforms/{org}/…
Analytics — Users
Analytics — Users
Analytics — Topics
Analytics — Topics
Analytics — Topics details
Analytics — Topics details
Analytics — Transcripts: conversation list and transcript panel
Analytics — Transcripts: conversation list and transcript panel
Analytics — Transcripts: a turn's details (tools, metadata, request context)
Analytics — Transcripts: a turn's details (tools, metadata, request context)
Analytics — Cost → Spend
Analytics — Cost → Spend
Analytics — Cost → Usage & latency
Analytics — Cost → Usage & latency
Analytics — Cost → Usage & latency by model
Analytics — Cost → Usage & latency by model
Analytics — Courses (Active / Total Courses, Enrollments, Total Learners; Course Overtime)
Analytics — Courses (Active / Total Courses, Enrollments, Total Learners; Course Overtime)
Analytics — Programs (Active / Total Programs, Enrollments, Total Learners; Program Overtime)
Analytics — Programs (Active / Total Programs, Enrollments, Total Learners; Program Overtime)
Analytics — Data Reports (one card per report, download button)
Analytics — Data Reports (one card per report, download button)

Inside a transcript

The Transcripts panel replays a conversation the way the chat rendered it, from the per-turn context the backend attaches on admin surfaces. Every piece is optional and renders only when the payload carries it:

  • Attachments — files and images exchanged on a turn, as the chat's file cards (the presigned url opens them).
  • Retrieved Documents — the chunks the answer cited, with source, snippet and relevance score. Literally the chat's own control (RetrievedDocumentsButton, opening DocumentSidebar as a dialog): in the live composer it fetches the session's vector results, here it is handed the turn's — or the whole conversation's — documents instead. The agent History tab shows the same dialog (screenshot).
  • Used N tools — the turn's tool calls (name, input, output).
  • Show Details — per AI turn: Metadata (model with the provider logo, temperature, client) and Request Context (session, flow, org, pathway, system prompt, file references …). Platform names are used, not wire names: enable_artifacts → Canvas, is_proactive → Proactive Prompt, grading_context → Grader, document_filter → Datasets; unknown keys are humanised automatically.
  • Row badges — per conversation, how many documents and tool calls it used.
  • The user on a row is named by the shared resolveUserIdentity rule (full name → email → username → anonymous) and links to the profile dialog; pass currentSPA so 'lms' / 'skills' hosts get its Gradebook tab.

To render documents or tool calls with your own chat components instead of the built-in ones, pass the turnDetails slots:

<AnalyticsTranscriptsStats
  tenantKey={tenantKey}
  mentorId=""
  turnDetails={{
    renderDocuments: (documents) => <MyCitations documents={documents} />,
    renderToolCalls: (toolCalls) => <MyToolLog calls={toolCalls} />,
  }}
/>

The same panel powers the agent History tab (/iblai-vibe-agent-history), and its components are exported from @iblai/iblai-js/web-containers for custom transcript UI:

ExportWhat it renders
TranscriptTurnDetails, TranscriptRollupBadgesthe Show Details panel and the list-row document/tool badges
RetrievedDocumentsButton, DocumentSidebarthe Retrieved Documents dialog — pass sessionId for a live chat, documents for a transcript
ToolCallIndicator (+ getFriendlyToolName, getQueryLabel, formatResult)the tool-call record
TranscriptFileCardsattachment cards (name, contentType, url)
LlmModelBadgethe model chip with its provider logo
resolveUserIdentitythe shared name-a-user rule
summarizeTranscriptTurns, conversationDocuments, toRetrievedDocuments, toToolCallInfos, toRollupCountpayload → props helpers

Permissions and data caveats

  • Platform audit shows only to org admins or holders of /auditlogs/#list. Everyone else gets the Agent trail with no switch. Pass canViewPlatformAudit if you already resolved it. The API returns 403 regardless of what the UI shows.
  • Usage & latency / Traces need org-admin analytics access; other users see a permission notice, not an error.
  • Memory returns 403 → "no permission" card. Watchers only see the users they watch.
  • Costs are platform-marked-up USD — show them as-is. An empty or unavailable cost block means the cost store was unreachable, not zero spend.
  • Per-agent cost ranking is forward-only: only calls traced since agent tagging shipped carry an agent. The tab shows this notice itself.
  • Date defaults differ server-side: llm-usage defaults to today, while the other endpoints default to 30 days. The layout pins 30D for both; any hand-rolled request must send its own range.

One agent instead of the org

For analytics on a single agent (e.g. app/(app)/agents/[mentorId]/analytics/), pass the route's mentorId to every tab and omit showPicker: the page already knows its agent. Also drop the groups filter, and hide Audit unless the viewer holds /mentors/{mentorDbId}/#view_audit_logs (/iblai-vibe-rbac):

const basePath = `/agents/${mentorId}/analytics`;

<AnalyticsLayout
  currentPath={pathname}
  basePath={basePath}
  onTabChange={(tab) => router.push(tab ? `${basePath}/${tab}` : basePath)}
  excludeTabs={canViewAuditLogs ? ["courses", "programs"] : ["courses", "programs", "audit"]}
>
  {children}
</AnalyticsLayout>

The Audit tab alone is also its own skill: /iblai-vibe-agent-audit.

Verify

pnpm build && pnpm test, then touch test /analytics in the browser as /iblai-vibe-ops-test says: click every tab in the strip — none may 404; switch the agent picker and the date range, and check that the numbers change; read the console; check the phone width.

Notes

  • Redux store must include mentorReducer and mentorMiddleware.
  • initializeDataLayer() takes 5 args (data-layer v1.2+).
  • @reduxjs/toolkit deduped via webpack aliases in next.config.ts.
  • AnalyticsControlBar, AnalyticsAgentPicker, StatCard, ChartCardWrapper and the useLlmUsage / useLlmTraces hooks are exported too — use them for a custom analytics page instead of rebuilding the pieces.
  • To override the SDK gray background to white, in globals.css: .bg-\[\#f5f7fb\] { background-color: #fff !important; }

Analytics REST API — see /iblai-api-analytics (headless twin)

Every analytics endpoint and Data Report — URLs, required params, RBAC role names, response shapes — lives in the companion skill /iblai-api-analytics in this repo (kind api). It is installed with the rest of the skills and stays in sync with the backend:

npx skills add iblai/vibe --all

Then /iblai-api-analytics covers auth, the schema-first workflow, the analyst-shaped endpoint groupings (Overview / Costs / Users & engagement / Topics & conversations / Transcripts / Sessions & ratings / Course / Program / Pathway & skill / Per-user / Time-on-platform), the async Data Reports lifecycle, and a local snapshot of the live OpenAPI schema at references/analytics-schema.json.

The OpenAPI schema is the contract. Live at {dm_url}/api/docs/schema/?format=json (browsable at {dm_url}/api/docs/; {dm_url} = https://api.$DOMAIN/dm, DOMAIN from iblai.env, default iblai.app). Consult it before writing any analytics request. The /iblai-api-analytics skill's references/schema.md gives the fetch + drift-check routine.

In this app (frontend wiring)

  • Auth header: Authorization: Token <token>. The SDK attaches this automatically via SERVICES.DM.
  • Anchor: {dm_url} = https://api.$DOMAIN/dm (DOMAIN from iblai.env, default iblai.app) throughout the companion /iblai-api-analytics skill. Endpoints there are written {dm_url}/api/analytics/… and {dm_url}/api/reports/…. In TypeScript, dmUrl is the first arg passed to initializeDataLayer() (sourced from NEXT_PUBLIC_API_BASE_URL), so `${dmUrl}/api/analytics/…` composes the request URL.
  • Prefer SDK RTK Query hooks over hand-rolled fetch. Use get_api_query_info("<hookName>") in MCP to find the relevant hook before writing custom UI.

Copyright © ibl.ai | support@iblai.zendesk.com