Add custom analytics script

This commit is contained in:
Weves
2024-04-13 10:35:19 -07:00
committed by Chris Weaver
parent 81e9880d9d
commit c055dc1535
21 changed files with 264 additions and 38 deletions

View File

@@ -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()

View File

@@ -16,3 +16,8 @@ class EnterpriseSettings(BaseModel):
def check_validity(self) -> None:
return
class AnalyticsScriptUpload(BaseModel):
script: str
secret_key: str

View File

@@ -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
)

View File

@@ -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",
},
]
: [];

View File

@@ -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,

View File

@@ -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">

View File

@@ -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";

View File

@@ -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">&lt;script&gt;&lt;/script&gt;</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>
);
}

View 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>
);
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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} />

View File

@@ -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 {

View File

@@ -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

View File

@@ -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({

View File

@@ -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",
},
],
},
]

View File

@@ -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>
);
}