diff --git a/.gitignore b/.gitignore
index 72e4be7..d31306a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,4 @@ dist-ssr
coverage/
.sonar/
*.tsbuildinfo
+jwt_private_key.pem
diff --git a/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx b/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx
index a0b0770..e392757 100644
--- a/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx
+++ b/src/components/screens-component/chat-screen/components/bubbles/card-bubble.tsx
@@ -94,21 +94,41 @@ export function CardBubble({ message }: { readonly message: CardMessage }) {
p: ({ node, ...props }) => (
),
-
+
ol: ({ node, ...props }) => (
),
ul: ({ node, ...props }) => (
),
-
+
li: ({ node, ...props }) => (
-
+
),
-
+
strong: ({ node, ...props }) => (
),
+ table: ({ node, ...props }) => (
+
+ ),
+ thead: ({ node, ...props }) => (
+
+ ),
+ tbody: ({ node, ...props }) => (
+
+ ),
+ tr: ({ node, ...props }) => (
+
+ ),
+ th: ({ node, ...props }) => (
+ |
+ ),
+ td: ({ node, ...props }) => (
+ |
+ ),
}}
>
{message.body}
diff --git a/src/components/screens-component/chat-screen/components/message-list.tsx b/src/components/screens-component/chat-screen/components/message-list.tsx
index 8fb0046..d1c46a8 100644
--- a/src/components/screens-component/chat-screen/components/message-list.tsx
+++ b/src/components/screens-component/chat-screen/components/message-list.tsx
@@ -4,6 +4,13 @@ import { MessageChrome } from "./message-chrome";
import { ChatMessage } from "./bubbles/chat-types";
import { Bubble } from "./bubbles";
import { AILoader } from "./ai-loader";
+import {
+ markAnswerRendered,
+ logResponseEvent,
+ startTelemetry,
+ endTelemetry,
+} from "@/lib/telemetry";
+import { useChatStore } from "@/hooks/store/chat";
/* eslint-disable no-unused-vars */
type MessageListProps = {
@@ -16,6 +23,7 @@ type MessageListProps = {
export function MessageList(props: MessageListProps) {
const bottomRef = useRef(null);
+ const loggedResponseQidsRef = useRef>(new Set());
useEffect(() => {
if (props.messages.length > 0) {
@@ -28,36 +36,40 @@ export function MessageList(props: MessageListProps) {
}
}
- // Performance Tracking
- const lastMessage = props.messages[props.messages.length - 1];
- if (
- lastMessage &&
- lastMessage.role === "assistant" &&
- lastMessage.type === "card" &&
- lastMessage.questionId &&
- !props.isAssistantTyping // Only log when fully done? Or maybe streams behave differently.
- // In original OAN-UI it seemed to log after paint.
- // We'll trust that isAssistantTyping false means done.
- ) {
- // We need to import these functions or pass them down.
- // Ideally we'd import them directly since they are singletons/globals basically.
- import("@/lib/telemetry").then(({ markAnswerRendered, logResponseEvent }) => {
- // We need the session ID, which isn't in props.
- // But the store has it. Or we can just rely on the global state in telemetry if it persists?
- // Actually `logResponseEvent` needs sessionId.
- // Maybe we should pass sessionId to MessageList?
- // Or we can import the store to get sessionId.
- import("@/hooks/store/chat").then(({ useChatStore }) => {
- const sessionId = useChatStore.getState().sessionId;
- if(sessionId && lastMessage.questionText && lastMessage.body) {
- const pipeline = lastMessage.type === "card" && "pipeline" in lastMessage ? lastMessage.pipeline : undefined;
- markAnswerRendered(lastMessage.questionId!, () => {
- logResponseEvent(lastMessage.questionId!, sessionId, lastMessage.questionText!, lastMessage.body, pipeline);
- // endTelemetryWithWait is called in store action
- });
- }
- });
- });
+ // Performance + response telemetry tracking.
+ // Process all completed assistant messages so follow-up Q&A pairs are not skipped.
+ if (props.isAssistantTyping) return;
+
+ const sessionId = useChatStore.getState().sessionId;
+ const userDetails = useChatStore.getState().getUserForTelemetry();
+ if (!sessionId) return;
+
+ for (const msg of props.messages) {
+ if (msg.role !== "assistant" || msg.type !== "card") continue;
+ if (!msg.questionId || !msg.questionText || !msg.body) continue;
+ if (!msg.showListenRow) continue;
+
+ const questionId = msg.questionId;
+ if (loggedResponseQidsRef.current.has(questionId)) continue;
+
+ const pipeline = "pipeline" in msg ? msg.pipeline : undefined;
+ loggedResponseQidsRef.current.add(questionId);
+ markAnswerRendered(questionId, async () => {
+ try {
+ await startTelemetry(sessionId, userDetails);
+ logResponseEvent(
+ questionId,
+ sessionId,
+ msg.questionText!,
+ msg.body,
+ pipeline,
+ );
+ endTelemetry();
+ } catch (error) {
+ console.warn("Telemetry failed (response event)", error);
+ loggedResponseQidsRef.current.delete(questionId);
+ }
+ });
}
}, [props.messages.length, props.isAssistantTyping, props.messages]);
diff --git a/src/hooks/store/chat/index.ts b/src/hooks/store/chat/index.ts
index 284f28e..4dfe197 100644
--- a/src/hooks/store/chat/index.ts
+++ b/src/hooks/store/chat/index.ts
@@ -124,6 +124,12 @@ function makeAssistantMessage(text: string, isError = false, showListenRow = fal
};
}
+function normalizeAssistantBodyForDisplay(text: string): string {
+ // Backend guardrail prefixes milk-collection payloads with a success line.
+ // Remove that prefix so the chat starts directly with markdown sections/tables.
+ return text.replace(/^Farmer milk collection details fetched successfully:\s*\n*/i, "");
+}
+
import { playTTS as playTTSHelper } from "@/lib/audio-utils";
import { ANONYMOUS_BOOTSTRAP_SESSION_KEY } from "@/lib/anonymous-bootstrap";
@@ -297,17 +303,18 @@ export const useChatStore = create((set, get) => ({
}
set((state) => {
+ const displayBody = normalizeAssistantBodyForDisplay(streamingText);
const lastMsg = state.messages[state.messages.length - 1];
if (lastMsg && lastMsg.role === "assistant" && lastMsg.type === "card") {
return {
messages: [
...state.messages.slice(0, -1),
- { ...lastMsg, body: streamingText }
+ { ...lastMsg, body: displayBody }
]
};
} else {
return {
- messages: [...state.messages, { ...makeAssistantMessage(streamingText), questionId, questionText: trimmed, pipeline: useTranslationPipeline ? 'oss_translate' : 'default' }]
+ messages: [...state.messages, { ...makeAssistantMessage(displayBody), questionId, questionText: trimmed, pipeline: useTranslationPipeline ? 'oss_translate' : 'default' }]
};
}
});
@@ -329,14 +336,6 @@ export const useChatStore = create((set, get) => ({
return { isAssistantTyping: false };
});
- try {
- const userDetailsResponse = get().getUserForTelemetry();
- await telemetry.startTelemetry(currentSession, userDetailsResponse);
- await telemetry.endTelemetryWithWait(questionId);
- } catch (e) {
- console.warn("Telemetry failed (response event)", e);
- }
-
// Use inline suggestions from stream if available, fall back to API
const parsedInlineSuggestions = Array.isArray(inlineSuggestions) ? inlineSuggestions : [];
if (parsedInlineSuggestions.length > 0) {
@@ -532,7 +531,8 @@ export const useChatStore = create((set, get) => ({
const msg = messages.find(m => m.id === messageId);
if (!msg) return;
- const userMsg = messages.findLast((m) => m.role === 'user');
+ const feedbackQuestionId = msg.questionId || messageId;
+ const userMsg = messages.findLast((m) => m.role === 'user' && m.questionId === msg.questionId);
const questionText = userMsg && userMsg.type === 'text' ? userMsg.text : "";
const responseText = msg && msg.type === 'card' ? msg.body : "";
const feedbackType = isPositive ? "like" : "dislike";
@@ -547,7 +547,7 @@ export const useChatStore = create((set, get) => ({
email: user?.email || ""
});
telemetry.logFeedbackEvent(
- messageId,
+ feedbackQuestionId,
sessionId,
feedbackMsg,
feedbackType,
diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts
index 6037529..3033b04 100644
--- a/src/lib/telemetry.ts
+++ b/src/lib/telemetry.ts
@@ -2,6 +2,7 @@
import FingerprintJS from "@fingerprintjs/fingerprintjs";
import { UAParser } from "ua-parser-js";
import { env } from "@/config/env";
+import { authState } from "@/hooks/store/auth";
// FingerprintJS initialization
@@ -78,11 +79,38 @@ const mapOSCode = (name = "") =>
const mapDeviceCode = (type = "") =>
({ mobile: "MB", tablet: "TB", desktop: "DT" })[type?.toLowerCase()] || "DT";
-// Declare V3 Telemetry methods required for this implementation
-// Note: Implementations for all methods are assumed to exist in the global Telemetry object.
+// Declare V3 Telemetry methods required for this implementation.
+// NOTE: Backend telemetry is sent via authenticated fetch (sendTelemetryToBackend).
+// During rollout we ALSO keep the legacy Sunbird telemetry pathway open so the
+// existing collector/dashboards don't go dark while the new Langfuse-via-backend
+// path is validated. Flip LEGACY_TELEMETRY_ENABLED to false to retire it.
declare let Telemetry: any;
declare let AuthTokenGenerate: any;
+const LEGACY_TELEMETRY_ENABLED = true;
+
+const legacyTelemetryAvailable = (): boolean =>
+ LEGACY_TELEMETRY_ENABLED && typeof Telemetry !== "undefined";
+
+const sendTelemetryLegacy = (payload: Record) => {
+ try {
+ if (legacyTelemetryAvailable() && typeof Telemetry?.response === "function") {
+ Telemetry.response(payload);
+ }
+ } catch {
+ // Best-effort: never let the legacy SDK break the new pathway.
+ }
+};
+
+/**
+ * Emit one telemetry event to BOTH pathways (new backend + legacy Sunbird).
+ * Each sink is independently fault-isolated.
+ */
+const emitTelemetry = (payload: Record) => {
+ sendTelemetryToBackend(payload);
+ sendTelemetryLegacy(payload);
+};
+
// Function to get the current host URL
const getHostUrl = (): string => {
if (typeof window !== "undefined") {
@@ -91,6 +119,31 @@ const getHostUrl = (): string => {
return "unknown-host";
};
+const getTelemetryEndpoint = () => `${env.telemetryUrl}/action/data/v3/telemetry`;
+
+const getTelemetryHeaders = (): Record => {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ const { accessToken } = authState();
+ if (accessToken) {
+ headers.Authorization = `Bearer ${accessToken}`;
+ }
+ if (env.apiKey) {
+ headers.apikey = env.apiKey;
+ }
+ return headers;
+};
+
+const sendTelemetryToBackend = (payload: Record) => {
+ fetch(getTelemetryEndpoint(), {
+ method: "POST",
+ headers: getTelemetryHeaders(),
+ body: JSON.stringify(payload),
+ keepalive: true,
+ }).catch(() => {});
+};
+
// inititalize fingerprint and UAparser
const initFingerprintContext = async (sessionStartAt: number) => {
const cached = localStorage.getItem("fingerprint_context");
@@ -154,27 +207,25 @@ export const startTelemetry = async (
initChatApiPerformanceObserver();
- const key = "gyte5565fdbgbngfnhgmnhmjgm,jm,";
- const secret = "gnjhgjugkk";
- const config = {
- pdata: {
- id: "AmulAI",
- ver: "v0.1",
- pid: "AmulAI",
- },
- channel: "AmulAI-" + getHostUrl(),
- sid: sessionId,
- uid: userDetailsObj["preferred_username"] || "DEFAULT-USER",
- did: userDetailsObj["email"] || "DEFAULT-USER",
- authtoken: "",
- host: env.telemetryUrl,
- };
-
- const startEdata = {};
- const options = {};
- const token = AuthTokenGenerate.generate(key, secret);
- config.authtoken = token;
- Telemetry.start(config, "content_id", "contetn_ver", startEdata, options);
+ // Legacy Sunbird session start (kept open during rollout).
+ try {
+ if (legacyTelemetryAvailable() && typeof Telemetry?.start === "function") {
+ const key = "gyte5565fdbgbngfnhgmnhmjgm,jm,";
+ const secret = "gnjhgjugkk";
+ const config = {
+ pdata: { id: "AmulAI", ver: "v0.1", pid: "AmulAI" },
+ channel: "AmulAI-" + getHostUrl(),
+ sid: sessionId,
+ uid: userDetailsObj["preferred_username"] || "DEFAULT-USER",
+ did: userDetailsObj["email"] || "DEFAULT-USER",
+ authtoken: AuthTokenGenerate.generate(key, secret),
+ host: "/observability-service",
+ };
+ Telemetry.start(config, "content_id", "contetn_ver", {}, {});
+ }
+ } catch {
+ // Best-effort: legacy start must never block the new pathway.
+ }
};
export const markServerRequestStart = (qid: string) => {
@@ -229,7 +280,7 @@ export const logQuestionEvent = (
channel: "AmulAI-" + getHostUrl(),
};
- Telemetry.response(questionData);
+ emitTelemetry(questionData);
};
export const logResponseEvent = (
@@ -281,7 +332,7 @@ export const logResponseEvent = (
values: [],
};
- Telemetry.response(responseData);
+ emitTelemetry(responseData);
};
export const logErrorEvent = (
@@ -311,7 +362,7 @@ export const logErrorEvent = (
channel: "AmulAI-" + getHostUrl(),
};
- Telemetry.response(errorData);
+ emitTelemetry(errorData);
};
/** Optional feedback metadata: service that generated the response, pipeline, and 1–5 rating */
@@ -366,7 +417,7 @@ export const logFeedbackEvent = (
channel: "AmulAI-" + getHostUrl(),
};
- Telemetry.response(feedbackData);
+ emitTelemetry(feedbackData);
};
/**
@@ -378,9 +429,6 @@ export const logAnonymousTokenIssued = (
sessionId: string,
deviceId: string,
) => {
- // Align with existing working telemetry endpoint:
- // e.g. https://amulai.in/observability-service/action/data/v3/telemetry
- const endpoint = `${env.telemetryUrl}/action/data/v3/telemetry`;
const payload = {
eid: "OE_ANONYMOUS_TOKEN_ISSUED",
ver: "2.2",
@@ -406,16 +454,18 @@ export const logAnonymousTokenIssued = (
etags: { partner: [] },
};
- fetch(endpoint, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- keepalive: true,
- }).catch(() => {});
+ sendTelemetryToBackend(payload);
};
export const endTelemetry = () => {
- Telemetry.end({});
+ // New pathway is per-event (no session to close). Close the legacy session.
+ try {
+ if (legacyTelemetryAvailable() && typeof Telemetry?.end === "function") {
+ Telemetry.end({});
+ }
+ } catch {
+ // Best-effort.
+ }
};
// Track when response data is ready for each question
@@ -443,7 +493,6 @@ export const endTelemetryWithWait = async (qid: string, timeout = 3000) => {
const timer = window.__RESPONSE_TIMERS__?.[qid];
if (timer?.responseEnd && timer?.paintTime) {
console.log(`Response data already captured for ${qid}`);
- Telemetry.end({});
return;
}
@@ -476,9 +525,6 @@ export const endTelemetryWithWait = async (qid: string, timeout = 3000) => {
console.warn(`Error waiting for response data: ${error}`);
}
- // Call telemetry endpoint
- Telemetry.end({});
-
// Cleanup
responseDataReady.delete(qid);
};
diff --git a/vite.config.ts b/vite.config.ts
index 785b3b2..153623f 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -55,6 +55,11 @@ export default defineConfig(({ mode }) => {
target: devProxyTarget,
changeOrigin: true,
secure: true
+ },
+ "/observability-service": {
+ target: devProxyTarget,
+ changeOrigin: true,
+ secure: true
}
}
: undefined