cards plus a conditional amber
+// callout; it is now a variant toggle, with the EC2 IAM prerequisite riding on
+// the EC2 variant's note where it is actually relevant.
+
+import { getImageURL } from "@/utils/zincutils";
+import type { CardSubstitutions, RichCardContent } from "../types";
+import {
+ AGENTS_REPO,
+ EC2_IAM_NOTE,
+ agentCode,
+ envIcons,
+ hostMetricsDetect,
+ sharedAgentTroubleshooting,
+} from "./osAgent";
+
+const install = (env: "" | "/ec2") =>
+ `curl -O ${AGENTS_REPO}/linux${env}/install.sh \\
+ && chmod +x install.sh \\
+ && sudo ./install.sh {url}/api/{org}/ {token}`;
+
+export default function linuxCard(subs: CardSubstitutions): RichCardContent {
+ const icon = envIcons();
+ return {
+ provider: {
+ name: "Linux",
+ tagline:
+ "Install the OpenObserve agent on any Linux host — system and journald logs plus CPU, memory, disk and network metrics.",
+ logo: getImageURL("images/common/linux.svg"),
+ tone: "#f5b53d",
+ runtime: "Host",
+ setupTime: "~1 min",
+ metaBadges: ["Logs", "Metrics"],
+ },
+ steps: [
+ {
+ id: "install",
+ title: "Install the Agent",
+ description:
+ "Run this on the host as **root** (or with `sudo`). Pick **AWS EC2** to additionally pick up instance metadata and use the instance's Name tag as the host identifier.",
+ chip: { kind: "terminal", label: "Terminal" },
+ required: true,
+ completeOn: "copy",
+ variants: [
+ {
+ id: "generic",
+ label: "Generic Linux",
+ icon: icon.linux,
+ code: agentCode(install(""), subs, "bash"),
+ note: "Any Linux server or VM.",
+ },
+ {
+ id: "ec2",
+ label: "AWS EC2",
+ icon: icon.ec2,
+ code: agentCode(install("/ec2"), subs, "bash"),
+ note: EC2_IAM_NOTE,
+ },
+ ],
+ },
+ {
+ id: "verify",
+ title: "Verify Data in OpenObserve",
+ description:
+ "The agent starts on install. Give it a few seconds, then hit Test — host metrics arrive as `system_*` streams.",
+ chip: { kind: "traces", label: "Metrics" },
+ completeOn: "detect",
+ detectionAnchor: true,
+ pills: [
+ "System Logs",
+ "journald",
+ "CPU",
+ "Memory",
+ "Disk",
+ "Network",
+ ],
+ },
+ ],
+ detect: hostMetricsDetect,
+ extras: {
+ fixTitle: "Check The Agent Service",
+ fixBody:
+ "The installer starts the agent as a systemd service. If nothing arrives, confirm it is running and read its logs for an auth or connectivity error:",
+ fixLang: "bash",
+ fixSnippet: `sudo systemctl status openobserve-agent
+sudo journalctl -u openobserve-agent -n 50 --no-pager`,
+ troubleshooting: sharedAgentTroubleshooting(
+ "`sudo systemctl status openobserve-agent`",
+ ),
+ },
+ docUrl: "https://github.com/openobserve/agents",
+ };
+}
diff --git a/web/src/components/ingestion/setupCard/content/osAgent.ts b/web/src/components/ingestion/setupCard/content/osAgent.ts
new file mode 100644
index 0000000000..29b282a458
--- /dev/null
+++ b/web/src/components/ingestion/setupCard/content/osAgent.ts
@@ -0,0 +1,90 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see
.
+
+// Shared pieces for the host-agent cards (Linux, Windows). Both install the same
+// OpenObserve agent from the openobserve/agents repo and differ only by shell,
+// script path and what the agent collects — so the environment choice
+// (generic host vs AWS EC2), the EC2 IAM prerequisite and the detection config
+// live here rather than being duplicated in both cards.
+
+import { getImageURL } from "@/utils/zincutils";
+import type { CardSubstitutions, RichCardDetect, RichCardExtras } from "../types";
+import { applySubs, applySubsMasked } from "../subs";
+
+/** Base of the agent install scripts, e.g. `${AGENTS_REPO}/linux/install.sh`. */
+export const AGENTS_REPO =
+ "https://raw.githubusercontent.com/openobserve/agents/main";
+
+/** Environment the agent is installed onto — drives the script path. */
+export type AgentEnv = "generic" | "ec2";
+
+/**
+ * The EC2 variants read the instance's Name tag and use it as the host
+ * identifier, which needs an instance role — without it the agent installs but
+ * every host shows up under its opaque instance id instead.
+ */
+export const EC2_IAM_NOTE =
+ "Requires an IAM role on the instance with ec2:DescribeTags and ec2:DescribeInstances, so the agent can read the Name tag and use it as the host identifier.";
+
+/** Icon shown on the generic/EC2 toggle. */
+export const envIcons = () => ({
+ ec2: getImageURL("images/ingestion/aws.svg"),
+ linux: getImageURL("images/common/linux.svg"),
+ windows: getImageURL("images/common/windows.svg"),
+});
+
+/** Build a code block whose `[BASIC_PASSCODE]`-equivalent token is masked. */
+export function agentCode(template: string, subs: CardSubstitutions, lang: string) {
+ return {
+ lang,
+ raw: applySubs(template, subs),
+ masked: applySubsMasked(template, subs),
+ };
+}
+
+/**
+ * Host metrics land as one stream per metric (`system_cpu_time`, …), so — like
+ * the database cards — detection is keyword/existence based rather than a COUNT
+ * over one stream.
+ *
+ * TODO(detect): confirm the agent's emitted metric prefix on the ingest side;
+ * this assumes the OTel hostmetrics receiver's `system.*` naming.
+ */
+export const hostMetricsDetect: RichCardDetect = {
+ streamType: "metrics",
+ match: "keyword",
+ streamName: "system",
+ filter: "",
+};
+
+/** Troubleshooting rows common to both host agents. */
+export function sharedAgentTroubleshooting(
+ serviceHint: string,
+): NonNullable
{
+ return [
+ {
+ q: "The install script runs but no data arrives",
+ a: `Check the agent is running with ${serviceHint}. A 401 in its logs means the ingestion token is stale — re-copy the command from this page, which always carries this organization's current token.`,
+ },
+ {
+ q: "Hosts show up under an instance id instead of a name",
+ a: `That is the EC2 variant without its IAM role. ${EC2_IAM_NOTE} Attach the role, then restart the agent.`,
+ },
+ {
+ q: "The endpoint is unreachable from the host",
+ a: "The agent needs outbound access to this OpenObserve endpoint. Confirm with `curl -v` from the host itself — a proxy or egress firewall is the usual cause.",
+ },
+ ];
+}
diff --git a/web/src/components/ingestion/setupCard/content/otlpTraces.ts b/web/src/components/ingestion/setupCard/content/otlpTraces.ts
new file mode 100644
index 0000000000..91ff786fd8
--- /dev/null
+++ b/web/src/components/ingestion/setupCard/content/otlpTraces.ts
@@ -0,0 +1,187 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// OTLP traces setup card — the generic "point any OpenTelemetry exporter here"
+// page. There is nothing to install: the user already has an SDK or collector,
+// and needs the endpoint, the auth header and the stream name.
+//
+// The stream is a `streamInput`, so the name flows into the exporter config AND
+// the live detection — they can never drift apart.
+
+import config from "@/aws-exports";
+import { getImageURL } from "@/utils/zincutils";
+import type {
+ CardSubstitutions,
+ RichCardContent,
+ RichCardStepVariant,
+} from "../types";
+import { applySubs, applySubsMasked } from "../subs";
+
+// Collector exporter config. {stream} is filled live by the renderer.
+const HTTP_YAML = `exporters:
+ otlphttp/openobserve:
+ endpoint: {url}/api/{org}
+ headers:
+ Authorization: Basic {token}
+ stream-name: {stream}
+
+service:
+ pipelines:
+ traces:
+ exporters: [otlphttp/openobserve]`;
+
+const grpcYaml = (host: string, insecure: boolean) => `exporters:
+ otlp/openobserve:
+ endpoint: ${host}:5081
+ headers:
+ Authorization: "Basic {token}"
+ organization: {org}
+ stream-name: {stream}
+ tls:
+ insecure: ${insecure}
+
+service:
+ pipelines:
+ traces:
+ exporters: [otlp/openobserve]`;
+
+// Env vars for an SDK pointed straight at OpenObserve (no collector in between).
+const SDK_ENV = `export OTEL_EXPORTER_OTLP_ENDPOINT="{url}/api/{org}"
+export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic {token},stream-name={stream}"
+export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
+export OTEL_SERVICE_NAME="my-service"`;
+
+export default function otlpTracesCard(
+ subs: CardSubstitutions,
+): RichCardContent {
+ const isCloud = config.isCloud === "true";
+ // The gRPC endpoint is a self-hosted port; cloud terminates HTTP only.
+ const host = subs.url.replace(/^https?:\/\//, "").replace(/\/$/, "");
+ const insecure = !subs.url.startsWith("https://");
+
+ const code = (tpl: string, lang = "yaml") => ({
+ lang,
+ raw: applySubs(tpl, subs),
+ masked: applySubsMasked(tpl, subs),
+ });
+
+ const variants: RichCardStepVariant[] = [
+ {
+ id: "http",
+ label: "Collector · OTLP HTTP",
+ icon: getImageURL("images/ingestion/otlp.svg"),
+ code: { ...code(HTTP_YAML), filename: "config.yaml" },
+ note: "The default for the OpenTelemetry Collector — works everywhere, including behind proxies and load balancers.",
+ },
+ {
+ id: "sdk",
+ label: "SDK (No Collector)",
+ icon: getImageURL("images/rum/events/terminal.png"),
+ code: code(SDK_ENV, "bash"),
+ note: "Standard OTEL_* environment variables — every OpenTelemetry SDK reads these, so no code change is needed.",
+ },
+ ];
+
+ // gRPC is self-hosted only.
+ if (!isCloud) {
+ variants.splice(1, 0, {
+ id: "grpc",
+ label: "Collector · OTLP gRPC",
+ icon: getImageURL("images/ingestion/otlp.svg"),
+ code: { ...code(grpcYaml(host, insecure)), filename: "config.yaml" },
+ note: "Lower overhead than HTTP for high span volumes. Port 5081 must be reachable from the collector.",
+ });
+ }
+
+ return {
+ provider: {
+ name: "Traces (OpenTelemetry)",
+ tagline:
+ "Point any OpenTelemetry exporter at OpenObserve — collector or SDK, HTTP or gRPC. Your endpoint and token are filled in below.",
+ logo: getImageURL("images/ingestion/otlp.svg"),
+ tone: "#f5a800",
+ runtime: "Any",
+ setupTime: "~2 min",
+ metaBadges: ["Traces"],
+ },
+ steps: [
+ {
+ id: "configure",
+ title: "Point Your Exporter at OpenObserve",
+ description:
+ "Add this to your collector config, or set the environment variables if your app exports directly. Everything is already filled in for this organization.",
+ chip: { kind: "editor", label: "Editor" },
+ required: true,
+ completeOn: "copy",
+ variants,
+ },
+ {
+ id: "verify",
+ title: "Verify Data in OpenObserve",
+ description:
+ "Restart the collector (or your app), exercise a request that produces a span, then hit Test.",
+ chip: { kind: "traces", label: "Traces" },
+ completeOn: "detect",
+ detectionAnchor: true,
+ pills: ["Spans", "Service Map", "Latency", "Errors"],
+ },
+ ],
+ streamInput: {
+ label: "Traces Stream Name",
+ default: "default",
+ placeholder: "default",
+ help: "Written into the config above as `stream-name`, and watched by the check below.",
+ },
+ // Every span carries a trace id, so any row on the target stream proves the
+ // exporter is wired up correctly.
+ detect: {
+ streamType: "traces",
+ streamName: "default",
+ filter: "trace_id IS NOT NULL",
+ },
+ extras: {
+ fixTitle: "Check The Exporter Is Actually Wired Into The Pipeline",
+ fixBody:
+ "Defining an exporter is not enough — it has to be listed in a pipeline. This is the single most common reason a correct-looking config sends nothing:",
+ fixLang: "yaml",
+ fixSnippet: `service:
+ pipelines:
+ traces:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [otlphttp/openobserve] # <- must be listed here`,
+ troubleshooting: [
+ {
+ q: "The collector logs 401 or 403",
+ a: "The `Authorization` header must be `Basic ` using this organization's ingestion token — the value above is already correct. If it was rotated, re-copy from this page.",
+ },
+ {
+ q: "Spans arrive but land in the wrong stream",
+ a: "The `stream-name` header decides the stream. Set it to the same name as the field above, otherwise the check below watches a stream nothing is written to.",
+ },
+ {
+ q: "gRPC connection fails with a TLS error",
+ a: `The \`tls.insecure\` flag must match the endpoint's scheme — it is set to \`${insecure}\` above based on this deployment's URL. A plain-HTTP endpoint needs \`insecure: true\`; an HTTPS one needs \`false\`.`,
+ },
+ {
+ q: "Should I use HTTP or gRPC?",
+ a: "HTTP is the safe default and traverses proxies and load balancers cleanly. gRPC has lower overhead at high span volume but needs port 5081 reachable end to end.",
+ },
+ ],
+ },
+ docUrl:
+ "https://openobserve.ai/docs/user-guide/data-sources-ingestion/traces/opentelemetry/",
+ };
+}
diff --git a/web/src/components/ingestion/setupCard/content/windows.ts b/web/src/components/ingestion/setupCard/content/windows.ts
new file mode 100644
index 0000000000..44b9026e42
--- /dev/null
+++ b/web/src/components/ingestion/setupCard/content/windows.ts
@@ -0,0 +1,111 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Windows host agent setup card — the PowerShell twin of ./linux.ts. Shared
+// scaffolding (environment toggle, EC2 IAM prerequisite, host-metrics detection)
+// comes from ./osAgent; this file holds the PowerShell command and the
+// Windows-specific collection list.
+
+import { getImageURL } from "@/utils/zincutils";
+import type { CardSubstitutions, RichCardContent } from "../types";
+import {
+ AGENTS_REPO,
+ EC2_IAM_NOTE,
+ agentCode,
+ envIcons,
+ hostMetricsDetect,
+ sharedAgentTroubleshooting,
+} from "./osAgent";
+
+const install = (env: "" | "/ec2") =>
+ `Invoke-WebRequest -Uri ${AGENTS_REPO}/windows${env}/install.ps1 -OutFile install.ps1
+.\\install.ps1 -URL {url}/api/{org}/ -AUTH_KEY {token}`;
+
+export default function windowsCard(subs: CardSubstitutions): RichCardContent {
+ const icon = envIcons();
+ return {
+ provider: {
+ name: "Windows",
+ tagline:
+ "Install the OpenObserve agent on any Windows host — Event Log, performance counters and host metrics.",
+ logo: getImageURL("images/common/windows.svg"),
+ tone: "#0078d4",
+ runtime: "Host",
+ setupTime: "~1 min",
+ metaBadges: ["Logs", "Metrics"],
+ },
+ steps: [
+ {
+ id: "install",
+ title: "Install the Agent",
+ description:
+ "Run this in **PowerShell as Administrator**. Pick **AWS EC2** to additionally pick up instance metadata and use the instance's Name tag as the host identifier.",
+ chip: { kind: "terminal", label: "PowerShell" },
+ required: true,
+ completeOn: "copy",
+ variants: [
+ {
+ id: "generic",
+ label: "Generic Windows",
+ icon: icon.windows,
+ code: agentCode(install(""), subs, "powershell"),
+ note: "Any Windows server or VM.",
+ },
+ {
+ id: "ec2",
+ label: "AWS EC2",
+ icon: icon.ec2,
+ code: agentCode(install("/ec2"), subs, "powershell"),
+ note: EC2_IAM_NOTE,
+ },
+ ],
+ },
+ {
+ id: "verify",
+ title: "Verify Data in OpenObserve",
+ description:
+ "The agent starts on install. Give it a few seconds, then hit Test — host metrics arrive as `system_*` streams.",
+ chip: { kind: "traces", label: "Metrics" },
+ completeOn: "detect",
+ detectionAnchor: true,
+ pills: [
+ "Application",
+ "Security",
+ "Setup",
+ "System",
+ "Performance Counters",
+ "Host Metrics",
+ ],
+ },
+ ],
+ detect: hostMetricsDetect,
+ extras: {
+ fixTitle: "Check The Agent Service",
+ fixBody:
+ "The installer registers the agent as a Windows service. If nothing arrives, confirm it is running and read the most recent entries from its log:",
+ fixLang: "powershell",
+ fixSnippet: `Get-Service -Name openobserve-agent
+Get-Content -Tail 50 "$env:ProgramData\\openobserve-agent\\agent.log"`,
+ troubleshooting: [
+ {
+ q: "The script fails to run at all",
+ a: "PowerShell blocks unsigned scripts by default. Run the console **as Administrator** and, if needed, allow the script for that session with `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass`.",
+ },
+ ...sharedAgentTroubleshooting("`Get-Service -Name openobserve-agent`"),
+ ],
+ },
+ docUrl: "https://github.com/openobserve/agents",
+ };
+}
diff --git a/web/src/components/ingestion/setupCard/registry.ts b/web/src/components/ingestion/setupCard/registry.ts
index 3c56cd1908..8ed69b00d3 100644
--- a/web/src/components/ingestion/setupCard/registry.ts
+++ b/web/src/components/ingestion/setupCard/registry.ts
@@ -35,6 +35,11 @@ import snowflake from "./content/snowflake";
import cassandra from "./content/cassandra";
import databricks from "./content/databricks";
import dynamodb from "./content/dynamodb";
+import kubernetes from "./content/kubernetes";
+import linux from "./content/linux";
+import windows from "./content/windows";
+import gcp from "./content/gcp";
+import otlpTraces from "./content/otlpTraces";
/** Given per-org substitutions, returns a data source's setup-card content. */
export type DataSourceCardBuilder = (
@@ -54,6 +59,11 @@ const registry: Record = {
cassandra,
databricks,
dynamoDB: dynamodb,
+ kubernetes,
+ linux,
+ windows,
+ gcp,
+ otlpTraces,
};
/** Whether a data source slug has an in-repo rich setup card. */
diff --git a/web/src/components/ingestion/setupCard/types.ts b/web/src/components/ingestion/setupCard/types.ts
index 4df7dd7e69..495436a41e 100644
--- a/web/src/components/ingestion/setupCard/types.ts
+++ b/web/src/components/ingestion/setupCard/types.ts
@@ -35,8 +35,13 @@ export interface CardSubstitutions {
/** Context of a step → drives both the title chip and the code-block chrome. */
export type StepChipKind = "terminal" | "editor" | "run" | "traces";
-/** How a step is marked complete: the user copies its code, or a span lands. */
-export type StepCompleteOn = "copy" | "detect";
+/**
+ * How a step is marked complete: the user copies its code, a span lands, or the
+ * user triggers the step's action button (see RichCardStepAction — used by the
+ * cloud-provider cards, whose steps launch a console wizard rather than
+ * producing a command to copy).
+ */
+export type StepCompleteOn = "copy" | "detect" | "action";
export interface RichCardChip {
kind: StepChipKind;
@@ -78,6 +83,24 @@ export interface RichCardStepVariant {
note?: string;
}
+/**
+ * A button that performs the step instead of handing over a command — e.g.
+ * "Launch CloudFormation Stack", which opens the AWS console. The renderer is
+ * presentational, so it only emits `step-action` with this id; the hosting page
+ * owns the behaviour.
+ */
+export interface RichCardStepAction {
+ /** Emitted with the `step-action` event so the page can dispatch. */
+ id: string;
+ label: string;
+ /** OIcon registry name rendered before the label. */
+ icon?: string;
+ /** Renders as a secondary button (default is primary). */
+ variant?: "primary" | "secondary";
+ /** Greys the button out — e.g. nothing selected yet. */
+ disabled?: boolean;
+}
+
export interface RichCardStep {
/** Stable id (also used as the scroll target for the next-step auto-advance). */
id: string;
@@ -113,6 +136,11 @@ export interface RichCardStep {
note?: string;
/** Monospace pills rendered after the description (e.g. captured attributes). */
pills?: string[];
+ /**
+ * Button that performs this step (cloud-console flows). Rendered after the
+ * step's own content and any `#step-` slot.
+ */
+ action?: RichCardStepAction;
completeOn: StepCompleteOn;
/** Marks the step as required (renders a "Required" emphasis on its chip). */
required?: boolean;
@@ -157,6 +185,19 @@ export interface RichCardExtras {
fixBody?: string;
/** Highlight language for the fix snippet (e.g. "bash"). Default "python". */
fixLang?: string;
+ /**
+ * An alternative, manual path to the same result — e.g. the raw Helm sequence
+ * behind Kubernetes' one-line installer. Rendered as a collapsed section below
+ * the steps, so the primary path stays the obvious default. Its code goes
+ * through the same live substitution as the steps ({stream} and step inputs).
+ */
+ advanced?: {
+ /** Accordion label, e.g. "Advanced Installation (Manual Steps)". */
+ label: string;
+ /** Optional paragraph above the code (inline markdown: **bold**, `code`). */
+ description?: string;
+ code: RichCardCode;
+ };
troubleshooting?: { q: string; a: string }[];
}
@@ -224,7 +265,15 @@ export interface RichCardContent {
/** When set, the card shows a stream-name input (see RichCardStreamInput). */
streamInput?: RichCardStreamInput;
extras?: RichCardExtras;
+ /** Primary "Full integration docs" link in the footer. */
docUrl?: string;
+ /**
+ * Additional reference links rendered as real anchors beside `docUrl` — for
+ * sources that legitimately have more than one guide (e.g. GCP's Pub/Sub and
+ * Google Workspace pages). Without this they end up as unclickable text
+ * inside a collapsed accordion, which is how they get lost.
+ */
+ docLinks?: { label: string; url: string }[];
slackUrl?: string;
}
diff --git a/web/src/components/ingestion/traces/OpenTelemetry.spec.ts b/web/src/components/ingestion/traces/OpenTelemetry.spec.ts
index 390f8353f2..ccc45a7663 100644
--- a/web/src/components/ingestion/traces/OpenTelemetry.spec.ts
+++ b/web/src/components/ingestion/traces/OpenTelemetry.spec.ts
@@ -13,540 +13,141 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-import { flushPromises, mount } from "@vue/test-utils";
-import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import OpenTelemetry from "@/components/ingestion/traces/OpenTelemetry.vue";
-import { nextTick } from "vue";
-import store from "@/test/unit/helpers/store";
-import i18n from "@/locales";
+// This component backs TWO routes (Recommended > Traces and Custom > Traces >
+// OpenTelemetry), so the card it renders is shared by both.
-// Mock services and utilities
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { mount, VueWrapper } from "@vue/test-utils";
+import { createStore } from "vuex";
+import { createI18n } from "vue-i18n";
+import { ref } from "vue";
+import OpenTelemetry from "./OpenTelemetry.vue";
+import { getDataSourceCard } from "@/components/ingestion/setupCard/registry";
+
+const mockConfig = { isCloud: "false" };
vi.mock("@/aws-exports", () => ({
- default: {
- isCloud: "false",
- enableAnalytics: "true"
- }
+ get default() {
+ return mockConfig;
+ },
}));
-vi.mock("@/utils/zincutils", async (importOriginal) => {
- const actual: any = await importOriginal();
- return {
- ...actual,
- getImageURL: vi.fn((path: string) => `/mocked/path/${path}`),
- getIngestionURL: vi.fn(() => "http://localhost:5080"),
- getEndPoint: vi.fn((url: string) => ({
- url: "http://localhost:5080",
- host: "localhost:5080",
- port: "5080",
- protocol: "http",
- tls: false
- }))
- };
+const mockEndpoint = ref({
+ url: "https://test.openobserve.ai",
+ host: "test.openobserve.ai",
+ port: 443,
+ protocol: "https",
+ tls: true,
});
+vi.mock("@/composables/useIngestion", () => ({
+ default: vi.fn(() => ({ endpoint: mockEndpoint })),
+}));
-describe("OpenTelemetry Component", () => {
- let wrapper: any = null;
+vi.mock("@/components/ingestion/setupCard/SetupCardRenderer.vue", () => ({
+ default: {
+ name: "SetupCardRenderer",
+ props: ["content", "subs", "logoUrl", "logoUrlDark"],
+ template: '',
+ },
+}));
- const mockProps = {
- currOrgIdentifier: "test-org",
- currUserEmail: "test@example.com"
- };
+const mockStore = createStore({
+ state: {
+ selectedOrganization: { identifier: "test-org", name: "Test Organization" },
+ userInfo: { email: "test@example.com" },
+ organizationData: { organizationPasscode: "test-passcode" },
+ theme: "light",
+ },
+});
+const mockI18n = createI18n({ locale: "en", messages: { en: {} } });
+const SUBS = {
+ url: "https://test.openobserve.ai",
+ org: "test-org",
+ token: "dGVzdEB0b2tlbg==",
+};
+
+const buildCard = async () =>
+ (await import("@/components/ingestion/setupCard/content/otlpTraces")).default(
+ SUBS,
+ );
+
+describe("otlpTracesCard builder", () => {
beforeEach(() => {
- wrapper = mount(OpenTelemetry, {
- props: mockProps,
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- // Reset mocks
- vi.clearAllMocks();
+ mockConfig.isCloud = "false";
});
- afterEach(() => {
- if (wrapper) {
- wrapper.unmount();
+ it("builds the OTLP card metadata and step flow", async () => {
+ const card = await buildCard();
+ expect(card.provider.metaBadges).toEqual(["Traces"]);
+ expect(card.steps.map((s) => s.id)).toEqual(["configure", "verify"]);
+ expect(card.detect).toMatchObject({
+ streamType: "traces",
+ filter: "trace_id IS NOT NULL",
+ });
+ });
+
+ it("offers HTTP, gRPC and SDK transports when self-hosted", async () => {
+ expect((await buildCard()).steps[0].variants?.map((v) => v.id)).toEqual([
+ "http",
+ "grpc",
+ "sdk",
+ ]);
+ });
+
+ it("drops gRPC on cloud, which terminates HTTP only", async () => {
+ mockConfig.isCloud = "true";
+ vi.resetModules();
+ expect((await buildCard()).steps[0].variants?.map((v) => v.id)).toEqual([
+ "http",
+ "sdk",
+ ]);
+ });
+
+ it("substitutes endpoint, org and token into every transport", async () => {
+ for (const v of (await buildCard()).steps[0].variants!) {
+ expect(v.code.raw).toContain(SUBS.org);
+ expect(v.code.raw).toContain(SUBS.token);
+ expect(v.code.raw).not.toContain("[BASIC_PASSCODE]");
+ expect(v.code.masked).not.toContain(SUBS.token);
}
});
- // Component Initialization Tests
- describe("Component Initialization", () => {
- it("should mount OpenTelemetry component", () => {
- expect(wrapper).toBeTruthy();
- });
-
- it("should initialize with correct component name", () => {
- expect(wrapper.vm.$options.name).toBe("traces-otlp");
- });
-
- it("should initialize with correct props", () => {
- expect(wrapper.exists()).toBe(true);
- expect(wrapper.exists()).toBe(true);
- });
-
- it("should initialize store instance", () => {
- expect(wrapper.vm.store).toBeTruthy();
- expect(wrapper.vm.store.state).toBeTruthy();
- });
-
- it("should initialize config object", () => {
- expect(wrapper.vm.config).toBeTruthy();
- expect(typeof wrapper.vm.config).toBe('object');
- });
-
- it("should initialize getImageURL function", () => {
- expect(wrapper.vm.getImageURL).toBeTruthy();
- expect(typeof wrapper.vm.getImageURL).toBe('function');
- });
-
- it("should have CopyContent component registered", () => {
- expect(wrapper.vm.$options.components.CopyContent).toBeTruthy();
- });
+ it("keeps the stream name in lockstep between config and detection", async () => {
+ const card = await buildCard();
+ expect(card.streamInput?.default).toBe("default");
+ // Every transport writes the stream the check below watches.
+ for (const v of card.steps[0].variants!) {
+ expect(v.code.raw).toContain("{stream}");
+ }
});
- // Endpoint Configuration Tests
- describe("Endpoint Configuration", () => {
- it("should initialize endpoint object with correct structure", () => {
- expect(wrapper.vm.endpoint).toBeTruthy();
- expect(wrapper.vm.endpoint).toHaveProperty('url');
- expect(wrapper.vm.endpoint).toHaveProperty('host');
- expect(wrapper.vm.endpoint).toHaveProperty('port');
- expect(wrapper.vm.endpoint).toHaveProperty('protocol');
- expect(wrapper.vm.endpoint).toHaveProperty('tls');
- });
+ it("derives the gRPC tls.insecure flag from the endpoint scheme", async () => {
+ const grpc = (await buildCard()).steps[0].variants!.find(
+ (v) => v.id === "grpc",
+ )!;
+ // HTTPS endpoint → insecure must be false, else the handshake fails.
+ expect(grpc.code.raw).toContain("insecure: false");
+ expect(grpc.code.raw).toContain("test.openobserve.ai:5081");
+ });
+});
- it("should set correct endpoint URL", () => {
- expect(wrapper.vm.endpoint.url).toBe("http://localhost:5080");
- });
+describe("OpenTelemetry.vue", () => {
+ let wrapper: VueWrapper;
- it("should set correct endpoint host", () => {
- expect(wrapper.vm.endpoint.host).toBe("localhost:5080");
- });
-
- it("should set correct endpoint port", () => {
- expect(wrapper.vm.endpoint.port).toBe("5080");
- });
-
- it("should set correct endpoint protocol", () => {
- expect(wrapper.vm.endpoint.protocol).toBe("http");
- });
-
- it("should set correct endpoint TLS setting", () => {
- expect(wrapper.vm.endpoint.tls).toBe(false);
- });
-
- it("should call getIngestionURL utility function", () => {
- // Test that the ingestionURL value is set correctly
- expect(wrapper.vm.ingestionURL).toBe("http://localhost:5080");
- });
-
- it("should call getEndPoint utility function", () => {
- // Test that the endpoint is properly configured
- expect(wrapper.vm.endpoint.url).toBe("http://localhost:5080");
- expect(wrapper.vm.endpoint.host).toBe("localhost:5080");
- });
-
- it("should handle ingestionURL properly", () => {
- expect(wrapper.vm.ingestionURL).toBe("http://localhost:5080");
- });
+ afterEach(() => {
+ if (wrapper) wrapper.unmount();
});
- // HTTP Traces Content Tests
- describe("HTTP Traces Content Generation", () => {
- it("should generate correct HTTP traces URL", () => {
- const expectedURL = `http://localhost:5080/api/default`;
- expect(wrapper.vm.copyHTTPTracesContentURL).toBe(expectedURL);
- });
-
- it("should generate correct HTTP traces passcode", () => {
- const expectedPasscode = `Basic [BASIC_PASSCODE]`;
- expect(wrapper.vm.copyHTTPTracesContentPasscode).toBe(expectedPasscode);
- });
-
- it("should use organization identifier from store in URL", () => {
- expect(wrapper.vm.copyHTTPTracesContentURL).toContain("default");
- });
-
- it("should handle different organization identifiers", () => {
- // Test with different store state
- const testWrapper = mount(OpenTelemetry, {
- props: mockProps,
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- // The URL should contain the organization identifier from the store
- expect(testWrapper.vm.copyHTTPTracesContentURL).toMatch(/\/api\/[^\/]+$/);
- testWrapper.unmount();
- });
-
- it("should construct HTTP URL with correct format", () => {
- const url = wrapper.vm.copyHTTPTracesContentURL;
- expect(url).toMatch(/^https?:\/\/.*\/api\/.*$/);
- });
-
- it("should use Basic authentication format", () => {
- expect(wrapper.vm.copyHTTPTracesContentPasscode).toMatch(/^Basic \[BASIC_PASSCODE\]$/);
+ it("renders the shared setup card for the otlpTraces slug", () => {
+ expect(getDataSourceCard("otlpTraces", SUBS)?.provider.name).toContain(
+ "Traces",
+ );
+ wrapper = mount(OpenTelemetry, {
+ global: { plugins: [mockStore, mockI18n] },
});
+ expect(wrapper.findComponent({ name: "SetupCardRenderer" }).exists()).toBe(
+ true,
+ );
});
-
- // gRPC Content Tests
- describe("gRPC Traces Content Generation", () => {
- it("should generate correct gRPC content structure", () => {
- const content = wrapper.vm.copyGRPCTracesContent;
- expect(content).toContain("endpoint:");
- expect(content).toContain("headers:");
- expect(content).toContain("Authorization:");
- expect(content).toContain("organization:");
- expect(content).toContain("stream-name:");
- expect(content).toContain("tls:");
- expect(content).toContain("insecure:");
- });
-
- it("should include correct endpoint host in gRPC content", () => {
- expect(wrapper.vm.copyGRPCTracesContent).toContain("endpoint: localhost:5080");
- });
-
- it("should include correct authorization in gRPC content", () => {
- expect(wrapper.vm.copyGRPCTracesContent).toContain('Authorization: "Basic [BASIC_PASSCODE]"');
- });
-
- it("should include correct organization in gRPC content", () => {
- expect(wrapper.vm.copyGRPCTracesContent).toContain("organization: default");
- });
-
- it("should include default stream name in gRPC content", () => {
- expect(wrapper.vm.copyGRPCTracesContent).toContain("stream-name: default");
- });
-
- it("should set insecure to true when protocol is http", () => {
- expect(wrapper.vm.copyGRPCTracesContent).toContain("insecure: true");
- });
-
- it("should handle HTTPS protocol correctly", () => {
- // Create a wrapper with HTTPS endpoint
- const httpsWrapper = mount(OpenTelemetry, {
- props: mockProps,
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- // Mock the endpoint to be HTTPS
- httpsWrapper.vm.endpoint.protocol = "https";
-
- // Regenerate the gRPC content
- const httpsGRPCContent = `endpoint: ${httpsWrapper.vm.endpoint.host}
-headers:
- Authorization: "Basic [BASIC_PASSCODE]"
- organization: ${httpsWrapper.vm.store.state.selectedOrganization.identifier}
- stream-name: default
-tls:
- insecure: ${httpsWrapper.vm.endpoint.protocol == "https" ? false : true}`;
-
- expect(httpsGRPCContent).toContain("insecure: false");
- httpsWrapper.unmount();
- });
-
- it("should generate multi-line gRPC content", () => {
- const lines = wrapper.vm.copyGRPCTracesContent.split('\n');
- expect(lines.length).toBeGreaterThan(5);
- });
-
- it("should use organization identifier from store in gRPC content", () => {
- expect(wrapper.vm.copyGRPCTracesContent).toContain("organization: default");
- });
- });
-
- // Template Rendering Tests
- describe("Template Rendering", () => {
- it("should render OTLP HTTP title", () => {
- const httpTitle = wrapper.find('[data-test="vector-title-text"]');
- expect(httpTitle.exists()).toBe(true);
- expect(httpTitle.text()).toContain("OTLP HTTP");
- });
-
- it("should render two CopyContent components for HTTP", () => {
- // Since CopyContent is stubbed, test that the component has the structure
- expect(wrapper.html()).toBeTruthy();
- // Test that the component has the expected content values
- expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
- expect(wrapper.vm.copyHTTPTracesContentPasscode).toBeTruthy();
- });
-
- it("should pass correct HTTP URL to first CopyContent", () => {
- const copyComponents = wrapper.findAll('CopyContent-stub');
- if (copyComponents.length > 0) {
- const firstCopy = copyComponents[0];
- expect(firstCopy.attributes('content')).toBe(wrapper.vm.copyHTTPTracesContentURL);
- } else {
- // Test that content is generated correctly even if component is stubbed
- expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
- }
- });
-
- it("should pass correct HTTP passcode to second CopyContent", () => {
- const copyComponents = wrapper.findAll('CopyContent-stub');
- if (copyComponents.length > 1) {
- const secondCopy = copyComponents[1];
- expect(secondCopy.attributes('content')).toBe(wrapper.vm.copyHTTPTracesContentPasscode);
- } else {
- // Test that content is generated correctly even if component is stubbed
- expect(wrapper.vm.copyHTTPTracesContentPasscode).toBeTruthy();
- }
- });
-
- it("should render OTLP gRPC section", () => {
- const titleElements = wrapper.findAll('[data-test="vector-title-text"]');
- expect(titleElements.length).toBe(2);
- expect(titleElements[1].text()).toContain("OTLP gRPC");
- });
-
- it("should pass gRPC content to third CopyContent", () => {
- const copyComponents = wrapper.findAll('CopyContent-stub');
- if (copyComponents.length > 2) {
- const thirdCopy = copyComponents[2];
- expect(thirdCopy.attributes('content')).toBe(wrapper.vm.copyGRPCTracesContent);
- } else {
- // Test that content is generated correctly even if component is stubbed
- expect(wrapper.vm.copyGRPCTracesContent).toBeTruthy();
- }
- });
-
- it("should render proper display content for HTTP endpoint", () => {
- const copyComponents = wrapper.findAll('CopyContent-stub');
- if (copyComponents.length > 0) {
- const firstCopy = copyComponents[0];
- expect(firstCopy.attributes('displaycontent')).toContain('HTTP Endpoint:');
- expect(firstCopy.attributes('displaycontent')).toContain(wrapper.vm.copyHTTPTracesContentURL);
- } else {
- // Test that content is generated correctly
- expect(wrapper.vm.copyHTTPTracesContentURL).toContain('http');
- }
- });
-
- it("should render proper display content for authorization", () => {
- const copyComponents = wrapper.findAll('CopyContent-stub');
- if (copyComponents.length > 1) {
- const secondCopy = copyComponents[1];
- expect(secondCopy.attributes('displaycontent')).toContain('Authorization:');
- expect(secondCopy.attributes('displaycontent')).toContain(wrapper.vm.copyHTTPTracesContentPasscode);
- } else {
- // Test that content is generated correctly
- expect(wrapper.vm.copyHTTPTracesContentPasscode).toContain('Basic');
- }
- });
- });
-
- // Props Handling Tests
- describe("Props Handling", () => {
- it("should handle currOrgIdentifier prop", () => {
- expect(wrapper.props().currOrgIdentifier).toBe("test-org");
- });
-
- it("should handle currUserEmail prop", () => {
- expect(wrapper.props().currUserEmail).toBe("test@example.com");
- });
-
- it("should handle undefined currOrgIdentifier", () => {
- const noPropsWrapper = mount(OpenTelemetry, {
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- expect(noPropsWrapper.props().currOrgIdentifier).toBeUndefined();
- noPropsWrapper.unmount();
- });
-
- it("should handle undefined currUserEmail", () => {
- const noPropsWrapper = mount(OpenTelemetry, {
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- expect(noPropsWrapper.props().currUserEmail).toBeUndefined();
- noPropsWrapper.unmount();
- });
-
- it("should accept string type for currOrgIdentifier", () => {
- const testWrapper = mount(OpenTelemetry, {
- props: { currOrgIdentifier: "different-org" },
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- expect(testWrapper.props().currOrgIdentifier).toBe("different-org");
- testWrapper.unmount();
- });
-
- it("should accept string type for currUserEmail", () => {
- const testWrapper = mount(OpenTelemetry, {
- props: { currUserEmail: "different@email.com" },
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- expect(testWrapper.props().currUserEmail).toBe("different@email.com");
- testWrapper.unmount();
- });
- });
-
- // Utility Functions Integration Tests
- describe("Utility Functions Integration", () => {
- it("should call getImageURL function", () => {
- const result = wrapper.vm.getImageURL("test-path");
- expect(result).toBe("/mocked/path/test-path");
- });
-
- it("should have access to getImageURL in component", () => {
- expect(typeof wrapper.vm.getImageURL).toBe("function");
- });
-
- it("should integrate with getIngestionURL", () => {
- // Test that the function produces the expected result
- expect(wrapper.vm.ingestionURL).toBeTruthy();
- expect(typeof wrapper.vm.ingestionURL).toBe('string');
- });
-
- it("should integrate with getEndPoint", () => {
- // Test that the endpoint object is properly structured
- expect(wrapper.vm.endpoint).toHaveProperty('url');
- expect(wrapper.vm.endpoint).toHaveProperty('host');
- expect(wrapper.vm.endpoint).toHaveProperty('port');
- expect(wrapper.vm.endpoint).toHaveProperty('protocol');
- });
- });
-
- // Edge Cases and Error Handling Tests
- describe("Edge Cases and Error Handling", () => {
- it("should handle empty organization identifier", () => {
- // Test with a mock store that has empty organization identifier
- const emptyOrgWrapper = mount(OpenTelemetry, {
- props: mockProps,
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
-
- expect(emptyOrgWrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
- expect(emptyOrgWrapper.vm.copyGRPCTracesContent).toBeTruthy();
- emptyOrgWrapper.unmount();
- });
-
- it("should handle different protocol schemes", () => {
- // The component should handle both http and https
- expect(wrapper.vm.endpoint.protocol).toBe("http");
- expect(wrapper.vm.copyGRPCTracesContent).toContain("insecure: true");
- });
-
- it("should handle null endpoint values gracefully", () => {
- // Test that the component doesn't crash with null values
- const nullEndpoint = {
- url: null,
- host: null,
- port: null,
- protocol: null,
- tls: null
- };
-
- // Component should still render without crashing
- expect(wrapper.vm.endpoint).toBeTruthy();
- });
-
- it("should handle special characters in organization identifier", () => {
- // The component should handle organization identifiers with special characters
- const url = wrapper.vm.copyHTTPTracesContentURL;
- expect(url).toBeTruthy();
- expect(typeof url).toBe('string');
- });
-
- it("should maintain content consistency", () => {
- // HTTP and gRPC content should use the same organization identifier
- const httpOrg = wrapper.vm.copyHTTPTracesContentURL.split('/').pop();
- const gRPCOrg = wrapper.vm.copyGRPCTracesContent.match(/organization: (.*)/)[1];
- expect(httpOrg).toBe(gRPCOrg);
- });
-
- it("should handle component unmounting", () => {
- expect(() => {
- wrapper.unmount();
- wrapper = mount(OpenTelemetry, {
- props: mockProps,
- global: {
- plugins: [store, i18n],
- stubs: {
- "CopyContent": true
- }
- }
- });
- }).not.toThrow();
- });
-
- it("should handle reactive data updates", async () => {
- const initialURL = wrapper.vm.copyHTTPTracesContentURL;
-
- // Simulate endpoint change
- wrapper.vm.endpoint.url = "https://new-host:443";
- await nextTick();
-
- // The URL should still be accessible
- expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
- });
- });
-
- // Integration Tests
- describe("Component Integration", () => {
- it("should integrate properly with Vuex store", () => {
- expect(wrapper.vm.store.state.selectedOrganization).toBeTruthy();
- expect(wrapper.vm.store.state.selectedOrganization.identifier).toBe("default");
- });
-
- it("should maintain data consistency across all content types", () => {
- const orgId = wrapper.vm.store.state.selectedOrganization.identifier;
-
- expect(wrapper.vm.copyHTTPTracesContentURL).toContain(orgId);
- expect(wrapper.vm.copyGRPCTracesContent).toContain(orgId);
- });
-
- it("should handle store state changes gracefully", async () => {
- // Verify initial state
- expect(wrapper.vm.copyHTTPTracesContentURL).toBeTruthy();
- expect(wrapper.vm.copyGRPCTracesContent).toBeTruthy();
-
- // Content should remain valid
- expect(wrapper.vm.copyHTTPTracesContentURL.length).toBeGreaterThan(0);
- expect(wrapper.vm.copyGRPCTracesContent.length).toBeGreaterThan(0);
- });
- });
-});
\ No newline at end of file
+});
diff --git a/web/src/components/ingestion/traces/OpenTelemetry.vue b/web/src/components/ingestion/traces/OpenTelemetry.vue
index 38341f4ae8..25312a4c1c 100644
--- a/web/src/components/ingestion/traces/OpenTelemetry.vue
+++ b/web/src/components/ingestion/traces/OpenTelemetry.vue
@@ -14,78 +14,24 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
-->
-
-
-
- OTLP HTTP
-
-
-
+
+
+
-
diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json
index a40dc73474..7ad48fcd46 100644
--- a/web/src/locales/languages/en-US.json
+++ b/web/src/locales/languages/en-US.json
@@ -2417,6 +2417,40 @@
"iis": "IIS",
"pubsub": "Pub/Sub Logs",
"aiLabel": "AI Integrations",
+ "azureSetup": {
+ "activityLogsTitle": "Azure Activity Logs",
+ "activityLogsDescription": "Stream Azure subscription activity logs to OpenObserve via Event Hub. The ARM template sets up the Event Hub infrastructure \u2014 you then configure Azure to export logs to it.",
+ "manualTitle": "Manual Configuration (for reference)",
+ "searchPlaceholder": "Search Azure services...",
+ "categoryAll": "All Services",
+ "categoryLogs": "Logs",
+ "categoryCompute": "Compute",
+ "categoryStorage": "Storage",
+ "categorySecurity": "Security",
+ "categoryNetworking": "Networking",
+ "noResults": "No integrations found matching your search"
+ },
+ "awsSetup": {
+ "title": "AWS Integrations",
+ "description": "Set up AWS monitoring in one click or configure individual services for granular control.",
+ "quickSetup": "Quick Setup",
+ "individualServices": "Individual Services",
+ "manualTitle": "Manual Configuration",
+ "manualDescription": "Use these credentials for custom AWS integrations or manual setup.",
+ "searchPlaceholder": "Search AWS services...",
+ "categoryAll": "All Services",
+ "categoryLogs": "Logs",
+ "categoryMetrics": "Metrics",
+ "categorySecurity": "Security",
+ "categoryNetworking": "Networking",
+ "noResults": "No integrations found matching your search",
+ "viewDocumentation": "View Documentation",
+ "addSource": "Add Source",
+ "documentation": "Documentation",
+ "addDashboard": "Add Dashboard",
+ "chooseMethod": "Choose Integration Method",
+ "chooseMethodPrompt": "Select how you want to integrate {service}:"
+ },
"mcp": {
"name": "OpenObserve MCP Server",
"shortName": "MCP Server",
diff --git a/web/src/locales/localeKeys.spec.ts b/web/src/locales/localeKeys.spec.ts
new file mode 100644
index 0000000000..6ce3150f0c
--- /dev/null
+++ b/web/src/locales/localeKeys.spec.ts
@@ -0,0 +1,134 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Guards the base locale file against a silent failure mode: JSON.parse accepts
+// duplicate keys and keeps the LAST one. Adding a nested block whose name
+// collides with an existing flat label silently replaces that label with an
+// object, and every t() call for it then renders the raw key path on screen.
+//
+// That is exactly what happened when an `"azure": { … }` block was added
+// alongside the existing `"azure": "Microsoft Azure"` label used by the Data
+// Sources sidebar tab and the global search index.
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+const LOCALE = resolve(__dirname, "./languages/en-US.json");
+
+/**
+ * Returns the names of keys that appear more than once within the same object.
+ * Walks the raw text because the duplicates are already gone by the time
+ * JSON.parse returns.
+ */
+export function findDuplicateKeys(text: string): string[] {
+ const dupes: string[] = [];
+ // One set of seen key names per open object.
+ const scopes: Array> = [];
+ let pendingKey: string | null = null;
+ let i = 0;
+
+ while (i < text.length) {
+ const ch = text[i];
+
+ if (ch === '"') {
+ // Read a complete string token, honouring escapes.
+ let value = "";
+ i++;
+ while (i < text.length && text[i] !== '"') {
+ if (text[i] === "\\") {
+ value += text[i] + text[i + 1];
+ i += 2;
+ continue;
+ }
+ value += text[i];
+ i++;
+ }
+ i++; // closing quote
+ pendingKey = value;
+ continue;
+ }
+
+ if (ch === "{") {
+ scopes.push(new Set());
+ pendingKey = null;
+ } else if (ch === "}") {
+ scopes.pop();
+ pendingKey = null;
+ } else if (ch === ":") {
+ // The string just read was a key in the innermost object.
+ const scope = scopes[scopes.length - 1];
+ if (scope && pendingKey !== null) {
+ if (scope.has(pendingKey)) dupes.push(pendingKey);
+ scope.add(pendingKey);
+ }
+ pendingKey = null;
+ } else if (ch === "," || ch === "[" || ch === "]") {
+ pendingKey = null;
+ }
+ i++;
+ }
+
+ return [...new Set(dupes)];
+}
+
+describe("findDuplicateKeys", () => {
+ it("detects a duplicate key within the same object", () => {
+ // The exact shape of the bug this guard exists for.
+ const bad = '{"ingestion":{"azure":"Microsoft Azure","azure":{"title":"x"}}}';
+ expect(findDuplicateKeys(bad)).toEqual(["azure"]);
+ });
+
+ it("does not flag the same name used in different objects", () => {
+ const ok = '{"a":{"title":"x"},"b":{"title":"y"}}';
+ expect(findDuplicateKeys(ok)).toEqual([]);
+ });
+
+ it("is not confused by braces or colons inside string values", () => {
+ const ok = '{"a":"{not: a key}","b":"plain"}';
+ expect(findDuplicateKeys(ok)).toEqual([]);
+ });
+});
+
+describe("en-US locale file", () => {
+ const raw = readFileSync(LOCALE, "utf8");
+
+ it("parses as valid JSON", () => {
+ expect(() => JSON.parse(raw)).not.toThrow();
+ });
+
+ it("has no duplicate keys within any object", () => {
+ expect(findDuplicateKeys(raw)).toEqual([]);
+ });
+
+ it("keeps the Data Sources sidebar labels as strings", () => {
+ // Rendered directly by Recommended.vue and the Ingestion search index — if
+ // any becomes an object, that tab shows its raw key path instead of a name.
+ const { ingestion } = JSON.parse(raw);
+ for (const key of [
+ "azure",
+ "kubernetes",
+ "windows",
+ "linux",
+ "awsconfig",
+ "gcpconfig",
+ "tracesotlp",
+ "rum",
+ ]) {
+ expect(typeof ingestion[key], `ingestion.${key}`).toBe("string");
+ expect(ingestion[key].length).toBeGreaterThan(0);
+ }
+ });
+});