mirror of
https://github.com/danswer-ai/danswer.git
synced 2025-09-19 12:03:54 +02:00
Add custom analytics script
This commit is contained in:
@@ -9,8 +9,11 @@ from danswer.auth.users import current_admin_user
|
||||
from danswer.db.engine import get_session
|
||||
from danswer.db.file_store import get_default_file_store
|
||||
from danswer.db.models import User
|
||||
from ee.danswer.server.enterprise_settings.models import AnalyticsScriptUpload
|
||||
from ee.danswer.server.enterprise_settings.models import EnterpriseSettings
|
||||
from ee.danswer.server.enterprise_settings.store import load_analytics_script
|
||||
from ee.danswer.server.enterprise_settings.store import load_settings
|
||||
from ee.danswer.server.enterprise_settings.store import store_analytics_script
|
||||
from ee.danswer.server.enterprise_settings.store import store_settings
|
||||
|
||||
|
||||
@@ -65,3 +68,18 @@ def fetch_logo(db_session: Session = Depends(get_session)) -> Response:
|
||||
# NOTE: specifying "image/jpeg" here, but it still works for pngs
|
||||
# TODO: do this properly
|
||||
return Response(content=file_io.read(), media_type="image/jpeg")
|
||||
|
||||
|
||||
@admin_router.put("/custom-analytics-script")
|
||||
def upload_custom_analytics_script(
|
||||
script_upload: AnalyticsScriptUpload, _: User | None = Depends(current_admin_user)
|
||||
) -> None:
|
||||
try:
|
||||
store_analytics_script(script_upload)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@basic_router.get("/custom-analytics-script")
|
||||
def fetch_custom_analytics_script() -> str | None:
|
||||
return load_analytics_script()
|
||||
|
@@ -16,3 +16,8 @@ class EnterpriseSettings(BaseModel):
|
||||
|
||||
def check_validity(self) -> None:
|
||||
return
|
||||
|
||||
|
||||
class AnalyticsScriptUpload(BaseModel):
|
||||
script: str
|
||||
secret_key: str
|
||||
|
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
from typing import cast
|
||||
|
||||
from danswer.dynamic_configs.factory import get_dynamic_config_store
|
||||
from danswer.dynamic_configs.interface import ConfigNotFoundError
|
||||
from ee.danswer.server.enterprise_settings.models import AnalyticsScriptUpload
|
||||
from ee.danswer.server.enterprise_settings.models import EnterpriseSettings
|
||||
|
||||
|
||||
@@ -23,3 +25,27 @@ def load_settings() -> EnterpriseSettings:
|
||||
|
||||
def store_settings(settings: EnterpriseSettings) -> None:
|
||||
get_dynamic_config_store().store(_ENTERPRISE_SETTINGS_KEY, settings.dict())
|
||||
|
||||
|
||||
_CUSTOM_ANALYTICS_SCRIPT_KEY = "__custom_analytics_script__"
|
||||
_CUSTOM_ANALYTICS_SECRET_KEY = os.environ.get("CUSTOM_ANALYTICS_SECRET_KEY")
|
||||
|
||||
|
||||
def load_analytics_script() -> str | None:
|
||||
dynamic_config_store = get_dynamic_config_store()
|
||||
try:
|
||||
return cast(str, dynamic_config_store.load(_CUSTOM_ANALYTICS_SCRIPT_KEY))
|
||||
except ConfigNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def store_analytics_script(analytics_script_upload: AnalyticsScriptUpload) -> None:
|
||||
if (
|
||||
not _CUSTOM_ANALYTICS_SECRET_KEY
|
||||
or analytics_script_upload.secret_key != _CUSTOM_ANALYTICS_SECRET_KEY
|
||||
):
|
||||
raise ValueError("Invalid secret key")
|
||||
|
||||
get_dynamic_config_store().store(
|
||||
_CUSTOM_ANALYTICS_SCRIPT_KEY, analytics_script_upload.script
|
||||
)
|
||||
|
@@ -27,8 +27,8 @@ const nextConfig = {
|
||||
},
|
||||
// analytics / audit log pages
|
||||
{
|
||||
source: "/admin/performance/analytics",
|
||||
destination: "/ee/admin/performance/analytics",
|
||||
source: "/admin/performance/usage",
|
||||
destination: "/ee/admin/performance/usage",
|
||||
},
|
||||
{
|
||||
source: "/admin/performance/query-history",
|
||||
@@ -43,6 +43,11 @@ const nextConfig = {
|
||||
source: "/admin/whitelabeling",
|
||||
destination: "/ee/admin/whitelabeling",
|
||||
},
|
||||
// custom analytics/tracking
|
||||
{
|
||||
source: "/admin/performance/custom-analytics",
|
||||
destination: "/ee/admin/performance/custom-analytics",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
|
@@ -6,7 +6,7 @@ import { Settings } from "./interfaces";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DefaultDropdown, Option } from "@/components/Dropdown";
|
||||
import { useContext } from "react";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProviderClientSideHelper";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
|
||||
function Checkbox({
|
||||
label,
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProviderClientSideHelper";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { useContext } from "react";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
@@ -15,11 +15,13 @@ export function ChatBanner() {
|
||||
<div
|
||||
className={`
|
||||
z-[39]
|
||||
w-full
|
||||
h-[30px]
|
||||
bg-background-custom-header
|
||||
shadow-sm
|
||||
m-2
|
||||
rounded
|
||||
border-border
|
||||
border-b
|
||||
border
|
||||
flex`}
|
||||
>
|
||||
<div className="mx-auto text-emphasis text-sm flex flex-col">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProviderClientSideHelper";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { Button } from "@tremor/react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Label, SubLabel } from "@/components/admin/connectors/Field";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { Button, Text } from "@tremor/react";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
export function CustomAnalyticsUpdateForm() {
|
||||
const settings = useContext(SettingsContext);
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
const customAnalyticsScript = settings.customAnalyticsScript;
|
||||
|
||||
const [newCustomAnalyticsScript, setNewCustomAnalyticsScript] =
|
||||
useState<string>(customAnalyticsScript || "");
|
||||
const [secretKey, setSecretKey] = useState<string>("");
|
||||
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{popup}
|
||||
<div className="mb-4">
|
||||
<Label>Script</Label>
|
||||
<Text className="mb-3">
|
||||
Specify the Javascript that should run on page load in order to
|
||||
initialize your custom tracking/analytics.
|
||||
</Text>
|
||||
<Text className="mb-2">
|
||||
Do not include the{" "}
|
||||
<span className="font-mono"><script></script></span> tags.
|
||||
If you upload a script below but you are not recieving any events in
|
||||
your analytics platform, try removing all extra whitespace before each
|
||||
line of JavaScript.
|
||||
</Text>
|
||||
<textarea
|
||||
className={`
|
||||
border
|
||||
border-border
|
||||
rounded
|
||||
w-full
|
||||
py-2
|
||||
px-3
|
||||
mt-1
|
||||
h-28`}
|
||||
value={newCustomAnalyticsScript}
|
||||
onChange={(e) => setNewCustomAnalyticsScript(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Label>Secret Key</Label>
|
||||
<SubLabel>
|
||||
<>
|
||||
For security reasons, you must provide a secret key to update this
|
||||
script. This should be the value of the{" "}
|
||||
<i>CUSTOM_ANALYTICS_SECRET_KEY</i> environment variable set when
|
||||
initially setting up Danswer.
|
||||
</>
|
||||
</SubLabel>
|
||||
<input
|
||||
className={`
|
||||
border
|
||||
border-border
|
||||
rounded
|
||||
w-full
|
||||
py-2
|
||||
px-3
|
||||
mt-1`}
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={async () => {
|
||||
const response = await fetch(
|
||||
"/api/admin/enterprise-settings/custom-analytics-script",
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
script: newCustomAnalyticsScript,
|
||||
secret_key: secretKey,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
setPopup({
|
||||
type: "success",
|
||||
message: "Custom analytics script updated successfully!",
|
||||
});
|
||||
} else {
|
||||
const errorMsg = (await response.json()).detail;
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: `Failed to update custom analytics script: "${errorMsg}"`,
|
||||
});
|
||||
}
|
||||
setSecretKey("");
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
46
web/src/app/ee/admin/performance/custom-analytics/page.tsx
Normal file
46
web/src/app/ee/admin/performance/custom-analytics/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { CUSTOM_ANALYTICS_ENABLED } from "@/lib/constants";
|
||||
import { Callout, Text } from "@tremor/react";
|
||||
import { FiBarChart2 } from "react-icons/fi";
|
||||
import { CustomAnalyticsUpdateForm } from "./CustomAnalyticsUpdateForm";
|
||||
|
||||
function Main() {
|
||||
if (!CUSTOM_ANALYTICS_ENABLED) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-4">
|
||||
<Callout title="Custom Analytics is not enabled." color="red">
|
||||
To set up custom analytics scripts, please work with the team who
|
||||
setup Danswer in your organization to set the{" "}
|
||||
<i>CUSTOM_ANALYTICS_SECRET_KEY</i> environment variable.
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-8">
|
||||
This allows you to bring your own analytics tool to Danswer! Copy the
|
||||
Web snippet from your analytics provider into the box below, and we'll
|
||||
start sending usage events.
|
||||
</Text>
|
||||
|
||||
<CustomAnalyticsUpdateForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="pt-4 mx-auto container">
|
||||
<AdminPageTitle
|
||||
title="Custom Analytics"
|
||||
icon={<FiBarChart2 size={32} />}
|
||||
/>
|
||||
|
||||
<Main />
|
||||
</main>
|
||||
);
|
||||
}
|
@@ -4,7 +4,7 @@ import {
|
||||
ChatSessionSnapshot,
|
||||
QueryAnalytics,
|
||||
UserAnalytics,
|
||||
} from "./analytics/types";
|
||||
} from "./usage/types";
|
||||
import { useState } from "react";
|
||||
import { buildApiPath } from "@/lib/urlBuilder";
|
||||
import { Feedback } from "@/lib/types";
|
||||
|
@@ -13,7 +13,7 @@ import {
|
||||
import { Divider } from "@tremor/react";
|
||||
import { Select, SelectItem } from "@tremor/react";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { ChatSessionSnapshot } from "../analytics/types";
|
||||
import { ChatSessionSnapshot } from "../usage/types";
|
||||
import { timestampToReadableDate } from "@/lib/dateUtils";
|
||||
import { FiFrown, FiMinus, FiSmile } from "react-icons/fi";
|
||||
import { useState } from "react";
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bold, Text, Card, Title, Divider } from "@tremor/react";
|
||||
import { ChatSessionSnapshot, MessageSnapshot } from "../../analytics/types";
|
||||
import { ChatSessionSnapshot, MessageSnapshot } from "../../usage/types";
|
||||
import { FiBook } from "react-icons/fi";
|
||||
import { timestampToReadableDate } from "@/lib/dateUtils";
|
||||
import { BackButton } from "@/components/BackButton";
|
||||
|
@@ -6,6 +6,7 @@ import { QueryPerformanceChart } from "./QueryPerformanceChart";
|
||||
import { BarChartIcon } from "@/components/icons/icons";
|
||||
import { useTimeRange } from "../lib";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { FiActivity } from "react-icons/fi";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useTimeRange();
|
||||
@@ -13,7 +14,10 @@ export default function AnalyticsPage() {
|
||||
return (
|
||||
<main className="pt-4 mx-auto container">
|
||||
{/* TODO: remove this `dark` once we have a mode selector */}
|
||||
<AdminPageTitle title="Analytics" icon={<BarChartIcon size={32} />} />
|
||||
<AdminPageTitle
|
||||
title="Usage Statistics"
|
||||
icon={<FiActivity size={32} />}
|
||||
/>
|
||||
|
||||
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
|
||||
|
@@ -3,7 +3,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { EnterpriseSettings } from "@/app/admin/settings/interfaces";
|
||||
import { useContext, useState } from "react";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProviderClientSideHelper";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { Form, Formik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { fetchSettingsSS } from "@/components/settings/lib";
|
||||
import "./globals.css";
|
||||
|
||||
import { Inter } from "next/font/google";
|
||||
import { fetchSettingsSS } from "@/components/settings/lib";
|
||||
import { CUSTOM_ANALYTICS_ENABLED } from "@/lib/constants";
|
||||
import { SettingsProvider } from "@/components/settings/SettingsProvider";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -25,6 +26,16 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
{CUSTOM_ANALYTICS_ENABLED && combinedSettings.customAnalyticsScript && (
|
||||
<head>
|
||||
<script
|
||||
type="text/javascript"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: combinedSettings.customAnalyticsScript,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
)}
|
||||
<body
|
||||
className={`${inter.variable} font-sans text-default bg-background ${
|
||||
// TODO: remove this once proper dark mode exists
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { SettingsContext } from "./settings/SettingsProviderClientSideHelper";
|
||||
import { SettingsContext } from "./settings/SettingsProvider";
|
||||
import Image from "next/image";
|
||||
|
||||
export function Logo({
|
||||
|
@@ -21,7 +21,16 @@ import {
|
||||
} from "@/lib/userSS";
|
||||
import { EE_ENABLED } from "@/lib/constants";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FiCpu, FiImage, FiPackage, FiSettings, FiSlack, FiTool } from "react-icons/fi";
|
||||
import {
|
||||
FiActivity,
|
||||
FiBarChart2,
|
||||
FiCpu,
|
||||
FiImage,
|
||||
FiPackage,
|
||||
FiSettings,
|
||||
FiSlack,
|
||||
FiTool,
|
||||
} from "react-icons/fi";
|
||||
|
||||
export async function Layout({ children }: { children: React.ReactNode }) {
|
||||
const tasks = [getAuthTypeMetadataSS(), getCurrentUserSS()];
|
||||
@@ -216,11 +225,11 @@ export async function Layout({ children }: { children: React.ReactNode }) {
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<BarChartIcon size={18} />
|
||||
<div className="ml-1">Analytics</div>
|
||||
<FiActivity size={18} />
|
||||
<div className="ml-1">Usage Statistics</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/analytics",
|
||||
link: "/admin/performance/usage",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
@@ -231,6 +240,15 @@ export async function Layout({ children }: { children: React.ReactNode }) {
|
||||
),
|
||||
link: "/admin/performance/query-history",
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex">
|
||||
<FiBarChart2 size={18} />
|
||||
<div className="ml-1">Custom Analytics</div>
|
||||
</div>
|
||||
),
|
||||
link: "/admin/performance/custom-analytics",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CombinedSettings } from "@/app/admin/settings/interfaces";
|
||||
import { createContext } from "react";
|
||||
|
||||
export const SettingsContext = createContext<CombinedSettings | null>(null);
|
||||
|
||||
export function SettingsProviderClientSideHelper({
|
||||
children,
|
||||
settings,
|
||||
}: {
|
||||
children: React.ReactNode | JSX.Element;
|
||||
settings: CombinedSettings;
|
||||
}) {
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user