From 4b5661b9817d1e0d67a8574d7c5931d3e892a006 Mon Sep 17 00:00:00 2001 From: MananTank Date: Sat, 11 Jan 2025 00:22:11 +0000 Subject: [PATCH] [TOOL-3007] Dashboard: Replace chainsaw with insight (#5926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview This PR focuses on enhancing the analytics functionality within the `dashboard` application by introducing new utility functions, updating existing API calls, and improving the data handling for contract analytics. ### Detailed summary - Added export of `toEventSelector` from `thirdweb/utils`. - Introduced `INSIGHT_SERVICE_API_KEY` in environment constants. - Modified `ContractAnalyticsPageClient` to accept new props for function and event selectors. - Created new analytics functions for total contract events, unique wallets, and transactions. - Updated `isAnalyticsSupportedForChain` to use `INSIGHT_SERVICE_API_KEY`. - Refactored analytics components to use new hooks for contract events and functions. - Improved data fetching and error handling in analytics hooks. - Enhanced UI components for displaying analytics data with loading states. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .changeset/blue-ears-sit.md | 5 + .../@/components/blocks/charts/area-chart.tsx | 118 +++---- .../@/components/blocks/charts/bar-chart.tsx | 82 ++--- apps/dashboard/src/@/constants/env.ts | 3 + .../_utils/isAnalyticsSupportedForChain.ts | 28 +- .../ContractAnalyticsPage.client.tsx | 10 +- .../analytics/ContractAnalyticsPage.tsx | 296 ++++++++++-------- .../[contractAddress]/analytics/page.tsx | 53 +++- .../overview/components/Analytics.tsx | 43 ++- .../pay/PayAnalytics/PayAnalytics.tsx | 124 +------- .../analytics/contract-event-breakdown.ts | 110 +++++++ .../src/data/analytics/contract-events.ts | 83 +++++ .../analytics/contract-function-breakdown.ts | 109 +++++++ .../data/analytics/contract-transactions.ts | 83 +++++ .../analytics/contract-wallet-analytics.ts | 83 +++++ apps/dashboard/src/data/analytics/hooks.ts | 295 +++-------------- .../data/analytics/total-contract-events.ts | 45 +++ .../analytics/total-contract-transactions.ts | 45 +++ .../data/analytics/total-unique-wallets.ts | 45 +++ .../api/server-proxy/chainsaw/[...paths].tsx | 41 --- packages/thirdweb/src/exports/utils.ts | 1 + turbo.json | 3 +- 22 files changed, 1050 insertions(+), 655 deletions(-) create mode 100644 .changeset/blue-ears-sit.md create mode 100644 apps/dashboard/src/data/analytics/contract-event-breakdown.ts create mode 100644 apps/dashboard/src/data/analytics/contract-events.ts create mode 100644 apps/dashboard/src/data/analytics/contract-function-breakdown.ts create mode 100644 apps/dashboard/src/data/analytics/contract-transactions.ts create mode 100644 apps/dashboard/src/data/analytics/contract-wallet-analytics.ts create mode 100644 apps/dashboard/src/data/analytics/total-contract-events.ts create mode 100644 apps/dashboard/src/data/analytics/total-contract-transactions.ts create mode 100644 apps/dashboard/src/data/analytics/total-unique-wallets.ts delete mode 100644 apps/dashboard/src/pages/api/server-proxy/chainsaw/[...paths].tsx diff --git a/.changeset/blue-ears-sit.md b/.changeset/blue-ears-sit.md new file mode 100644 index 00000000000..a8d4c66d1e5 --- /dev/null +++ b/.changeset/blue-ears-sit.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Export `toEventSelector` utility function from "thirdweb/utils" diff --git a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx index 75517c06f63..dc2fcc7524f 100644 --- a/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/area-chart.tsx @@ -8,8 +8,13 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { formatDate } from "date-fns"; import { useMemo } from "react"; import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; +import { + EmptyChartState, + LoadingChartState, +} from "../../../../components/analytics/empty-chart-state"; type ThirdwebAreaChartProps = { // chart config @@ -19,6 +24,7 @@ type ThirdwebAreaChartProps = { // chart className chartClassName?: string; + isPending: boolean; }; export function ThirdwebAreaChart( @@ -26,64 +32,70 @@ export function ThirdwebAreaChart( ) { const configKeys = useMemo(() => Object.keys(props.config), [props.config]); return ( -
+
- - - new Date(value).toLocaleDateString()} - /> - } /> - + {props.isPending ? ( + + ) : props.data.length === 0 ? ( + + ) : ( + + + formatDate(new Date(value), "MMM dd")} + /> + } /> + + {configKeys.map((key) => ( + + + + + ))} + {configKeys.map((key) => ( - - - - + dataKey={key} + type="natural" + fill={`url(#fill_${key})`} + fillOpacity={0.4} + stroke={`var(--color-${key})`} + stackId="a" + /> ))} - - {configKeys.map((key) => ( - - ))} - {props.showLegend && ( - } className="pt-8" /> - )} - + {props.showLegend && ( + } className="pt-8" /> + )} + + )}
); diff --git a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx index be1dc4bb411..3adbe34988a 100644 --- a/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx +++ b/apps/dashboard/src/@/components/blocks/charts/bar-chart.tsx @@ -17,7 +17,12 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { formatDate } from "date-fns"; import { useMemo } from "react"; +import { + EmptyChartState, + LoadingChartState, +} from "../../../../components/analytics/empty-chart-state"; type ThirdwebBarChartProps = { // metadata @@ -30,6 +35,7 @@ type ThirdwebBarChartProps = { variant?: "stacked" | "grouped"; // chart className chartClassName?: string; + isPending: boolean; }; export function ThirdwebBarChart( @@ -42,48 +48,54 @@ export function ThirdwebBarChart( return ( - {props.title} + {props.title} {props.description && ( {props.description} )} - - - new Date(value).toLocaleDateString()} - /> - } /> - {props.showLegend && ( - } /> - )} - {configKeys.map((key, idx) => ( - + ) : props.data.length === 0 ? ( + + ) : ( + + + formatDate(new Date(value), "MMM d")} /> - ))} - + } /> + {props.showLegend && ( + } /> + )} + {configKeys.map((key, idx) => ( + + ))} + + )} diff --git a/apps/dashboard/src/@/constants/env.ts b/apps/dashboard/src/@/constants/env.ts index 79c790ac975..78209f1b03a 100644 --- a/apps/dashboard/src/@/constants/env.ts +++ b/apps/dashboard/src/@/constants/env.ts @@ -41,3 +41,6 @@ export const BASE_URL = isProd : "http://localhost:3000") || "https://thirdweb-dev.com"; export const NEXT_PUBLIC_NEBULA_URL = process.env.NEXT_PUBLIC_NEBULA_URL; + +export const INSIGHT_SERVICE_API_KEY = + process.env.INSIGHT_SERVICE_API_KEY || ""; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/isAnalyticsSupportedForChain.ts b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/isAnalyticsSupportedForChain.ts index c093df1abbd..2f76daae0cb 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/isAnalyticsSupportedForChain.ts +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_utils/isAnalyticsSupportedForChain.ts @@ -1,35 +1,33 @@ -import { isProd } from "@/constants/env"; +import { INSIGHT_SERVICE_API_KEY } from "@/constants/env"; +import { getVercelEnv } from "lib/vercel-utils"; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; export async function isAnalyticsSupportedForChain( chainId: number, ): Promise { try { - if (!process.env.CHAINSAW_API_KEY) { - throw new Error("Missing CHAINSAW_API_KEY env var"); - } - const res = await fetch( - `https://chainsaw.${isProd ? "thirdweb" : "thirdweb-dev"}.com/service/chains/${chainId}`, + `https://insight.${thirdwebDomain}.com/service/chains/${chainId}`, { - method: "GET", headers: { - "content-type": "application/json", - // pass the shared secret - "x-service-api-key": process.env.CHAINSAW_API_KEY || "", + // service api key required - because this is endpoint is internal + "x-service-api-key": INSIGHT_SERVICE_API_KEY, }, }, ); if (!res.ok) { - // assume not supported if we get a non-200 response return false; } - const { data } = await res.json(); - return data; + const json = (await res.json()) as { data: boolean }; + + return json.data; } catch (e) { - console.error("Error checking if analytics is supported for chain", e); + console.error(`Error checking analytics support for chain ${chainId}`); + console.error(e); } - return false; } diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.client.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.client.tsx index ed8010bd465..8d214a962f0 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.client.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.client.tsx @@ -8,6 +8,8 @@ import { ContractAnalyticsPage } from "./ContractAnalyticsPage"; export function ContractAnalyticsPageClient(props: { contract: ThirdwebContract; + writeFnSelectorToNameRecord: Record; + eventSelectorToNameRecord: Record; }) { const metadataQuery = useContractPageMetadata(props.contract); @@ -23,5 +25,11 @@ export function ContractAnalyticsPageClient(props: { return ; } - return ; + return ( + + ); } diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.tsx index 2e78faee5e0..a92870bf4cd 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/ContractAnalyticsPage.tsx @@ -1,138 +1,117 @@ "use client"; import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart"; -import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect"; -import { - Alert, - AlertDescription, - AlertIcon, - AlertTitle, - Flex, - SimpleGrid, - Skeleton, - Stat, - StatLabel, - StatNumber, -} from "@chakra-ui/react"; +import { Skeleton, Stat, StatLabel, StatNumber } from "@chakra-ui/react"; import type { UseQueryResult } from "@tanstack/react-query"; import { type AnalyticsQueryParams, type TotalQueryResult, - useEventsAnalytics, - useFunctionsAnalytics, - useLogsAnalytics, - useTotalLogsAnalytics, - useTotalTransactionAnalytics, - useTotalWalletsAnalytics, - useTransactionAnalytics, - useUniqueWalletsAnalytics, + useContractEventAnalytics, + useContractEventBreakdown, + useContractFunctionBreakdown, + useContractTransactionAnalytics, + useContractUniqueWalletAnalytics, + useTotalContractEvents, + useTotalContractTransactionAnalytics, + useTotalContractUniqueWallets, } from "data/analytics/hooks"; import { Suspense, useMemo, useState } from "react"; import type { ThirdwebContract } from "thirdweb"; -import { Card, Heading } from "tw-components"; +import { Card } from "tw-components"; +import { + DateRangeSelector, + type Range, + getLastNDaysRange, +} from "../../../../../../components/analytics/date-range-selector"; interface ContractAnalyticsPageProps { contract: ThirdwebContract; + writeFnSelectorToNameRecord: Record; + eventSelectorToNameRecord: Record; } export const ContractAnalyticsPage: React.FC = ({ contract, + writeFnSelectorToNameRecord, + eventSelectorToNameRecord, }) => { - const [startDate] = useState( - (() => { - const date = new Date(); - date.setDate(date.getDate() - 30); - return date; - })(), - ); - const [endDate] = useState(new Date()); - - useIsomorphicLayoutEffect(() => { - window?.scrollTo({ top: 0, behavior: "smooth" }); - }, []); + const [range, setRange] = useState(() => getLastNDaysRange("last-30")); return ( - - {contract && ( - <> - - - - - Analytics is in beta. - - Some data may be partially inaccurate or incomplete. - - - - Analytics - - - - - - - - - - - - - - - - - - - - - )} - +
+

+ Analytics +

+ +
+ + + +
+ +
+ +
+ +
+ + + + + + + + + +
+
); }; @@ -143,15 +122,16 @@ type ChartProps = { endDate: Date; }; function UniqueWalletsChart(props: ChartProps) { - const { data } = useUniqueWalletsAnalytics(props); + const analyticsQuery = useContractUniqueWalletAnalytics(props); return ( ; + }, +) { + const analyticsQuery = useContractFunctionBreakdown(props); + + // replace function selector with function name + const mappedQueryData = useMemo(() => { + return analyticsQuery.data?.map((item) => { + const modifiedItem = { time: item.time } as typeof item; + + for (const key in item) { + if (key === "time") { + continue; + } + + const name = props.writeFnSelectorToNameRecord[key]; + const value = item[key]; + if (name && value !== undefined) { + modifiedItem[name] = value; + } + } + + return modifiedItem; + }); + }, [analyticsQuery.data, props.writeFnSelectorToNameRecord]); return ( { if (key === "time") { return acc; @@ -231,15 +239,41 @@ function FunctionBreakdownChart(props: ChartProps) { ); } -function EventBreakdownChart(props: ChartProps) { - const { data } = useEventsAnalytics(props); +function EventBreakdownChart( + props: ChartProps & { + eventSelectorToNameRecord: Record; + }, +) { + const analyticsQuery = useContractEventBreakdown(props); + + // replace event selector with event name + const mappedQueryData = useMemo(() => { + return analyticsQuery.data?.map((item) => { + const modifiedItem = { time: item.time } as typeof item; + + for (const key in item) { + if (key === "time") { + continue; + } + + const name = props.eventSelectorToNameRecord[key]; + const value = item[key]; + if (name && value !== undefined) { + modifiedItem[name] = value; + } + } + + return modifiedItem; + }); + }, [analyticsQuery.data, props.eventSelectorToNameRecord]); return ( { if (key === "time") { return acc; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/page.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/page.tsx index 0dea18958e1..402eea70bf7 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/analytics/page.tsx @@ -1,5 +1,7 @@ import { notFound, redirect } from "next/navigation"; import { localhost } from "thirdweb/chains"; +import { type ThirdwebContract, resolveContractAbi } from "thirdweb/contract"; +import { type Abi, toEventSelector, toFunctionSelector } from "thirdweb/utils"; import { getContractPageParamsInfo } from "../_utils/getContractFromParams"; import { getContractPageMetadata } from "../_utils/getContractPageMetadata"; import { ContractAnalyticsPage } from "./ContractAnalyticsPage"; @@ -18,8 +20,18 @@ export default async function Page(props: { notFound(); } + const { eventSelectorToName, writeFnSelectorToName } = await getSelectors( + info.contract, + ); + if (info.chainMetadata.chainId === localhost.id) { - return ; + return ( + + ); } const { isAnalyticsSupported } = await getContractPageMetadata(info.contract); @@ -28,5 +40,42 @@ export default async function Page(props: { redirect(`/${params.chain_id}/${params.contractAddress}`); } - return ; + return ( + + ); +} + +async function getSelectors(contract: ThirdwebContract) { + try { + const abi = await resolveContractAbi(contract); + const writeFnSelectorToName: Record = {}; + const eventSelectorToName: Record = {}; + + for (const item of abi) { + if (item.type === "event") { + eventSelectorToName[toEventSelector(item)] = item.name; + } else if ( + // if write function + item.type === "function" && + item.stateMutability !== "view" && + item.stateMutability !== "pure" + ) { + writeFnSelectorToName[toFunctionSelector(item)] = item.name; + } + } + + return { + writeFnSelectorToName, + eventSelectorToName, + }; + } catch { + return { + writeFnSelectorToName: {}, + eventSelectorToName: {}, + }; + } } diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/Analytics.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/Analytics.tsx index aa47e4783b2..23b98786e3b 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/Analytics.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/overview/components/Analytics.tsx @@ -3,9 +3,9 @@ import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart"; import { Button } from "@/components/ui/button"; import { - useLogsAnalytics, - useTransactionAnalytics, - useUniqueWalletsAnalytics, + useContractEventAnalytics, + useContractTransactionAnalytics, + useContractUniqueWalletAnalytics, } from "data/analytics/hooks"; import { useTrack } from "hooks/analytics/useTrack"; import { ArrowRightIcon } from "lucide-react"; @@ -76,31 +76,45 @@ type ChartProps = { endDate: Date; }; +function getDayKey(date: Date) { + return date.toISOString().split("T")[0]; +} + function OverviewAnalytics(props: ChartProps) { - const wallets = useUniqueWalletsAnalytics(props); - const transactions = useTransactionAnalytics(props); - const events = useLogsAnalytics(props); + const wallets = useContractUniqueWalletAnalytics(props); + const transactions = useContractTransactionAnalytics(props); + const events = useContractEventAnalytics(props); + const isPending = + wallets.isPending || transactions.isPending || events.isPending; const mergedData = useMemo(() => { + if (isPending) { + return undefined; + } + const time = (wallets.data || transactions.data || events.data || []).map( (wallet) => wallet.time, ); return time.map((time) => { - const wallet = wallets.data?.find((wallet) => wallet.time === time); + const wallet = wallets.data?.find( + (wallet) => getDayKey(wallet.time) === getDayKey(time), + ); const transaction = transactions.data?.find( - (transaction) => transaction.time === time, + (transaction) => getDayKey(transaction.time) === getDayKey(time), ); - const event = events.data?.find((event) => event.time === time); + const event = events.data?.find((event) => { + return getDayKey(event.time) === getDayKey(time); + }); return { time, - wallets: wallet?.wallets || 0, + wallets: wallet?.count || 0, transactions: transaction?.count || 0, events: event?.count || 0, }; }); - }, [wallets.data, transactions.data, events.data]); + }, [wallets.data, transactions.data, events.data, isPending]); return ( diff --git a/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx index bdcdaa7574a..e7c3389de1f 100644 --- a/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx +++ b/apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx @@ -1,15 +1,11 @@ "use client"; -import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { format, subDays } from "date-fns"; import { useState } from "react"; +import { + DateRangeSelector, + type Range, + getLastNDaysRange, +} from "../../analytics/date-range-selector"; import { PayCustomersTable } from "./components/PayCustomersTable"; import { PayNewCustomers } from "./components/PayNewCustomers"; import { PaymentHistory } from "./components/PaymentHistory"; @@ -18,38 +14,6 @@ import { Payouts } from "./components/Payouts"; import { TotalPayVolume } from "./components/TotalPayVolume"; import { TotalVolumePieChart } from "./components/TotalVolumePieChart"; -const durationPresets = [ - { - name: "Last 7 Days", - id: "last-7", - days: 7, - }, - { - name: "Last 30 Days", - id: "last-30", - days: 30, - }, - { - name: "Last 60 Days", - id: "last-60", - days: 60, - }, - { - name: "Last 120 Days", - id: "last-120", - days: 120, - }, -] as const; - -type DurationId = (typeof durationPresets)[number]["id"]; - -type Range = { - type: DurationId | "custom"; - label?: string; - from: Date; - to: Date; -}; - export function PayAnalytics(props: { clientId: string }) { const clientId = props.clientId; const [range, setRange] = useState(() => @@ -62,8 +26,8 @@ export function PayAnalytics(props: { clientId: string }) { return (
-
- +
+
@@ -124,80 +88,6 @@ export function PayAnalytics(props: { clientId: string }) { ); } -function getLastNDaysRange(id: DurationId) { - const durationInfo = durationPresets.find((preset) => preset.id === id); - if (!durationInfo) { - throw new Error("Invalid duration id"); - } - - const todayDate = new Date(); - const value: Range = { - type: id, - from: subDays(todayDate, durationInfo.days), - to: todayDate, - label: durationInfo.name, - }; - - return value; -} - -function Filters(props: { range: Range; setRange: (range: Range) => void }) { - const { range, setRange } = props; - - return ( -
- - setRange({ - from, - to: range.to, - type: "custom", - }) - } - setTo={(to) => - setRange({ - from: range.from, - to, - type: "custom", - }) - } - header={ -
- -
- } - labelOverride={range.label} - className="w-auto border-none p-0" - /> -
- ); -} - function GridWithSeparator(props: { children: React.ReactNode }) { return (
diff --git a/apps/dashboard/src/data/analytics/contract-event-breakdown.ts b/apps/dashboard/src/data/analytics/contract-event-breakdown.ts new file mode 100644 index 00000000000..45ba45e839d --- /dev/null +++ b/apps/dashboard/src/data/analytics/contract-event-breakdown.ts @@ -0,0 +1,110 @@ +import { getUnixTime } from "date-fns"; +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/env"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +type InsightAggregationEntry = { + event_signature: string; + time: string; + count: number; +}; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [Record]; +}; + +type EventBreakdownEntry = Record & { + time: Date; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getContractEventBreakdown(params: { + contractAddress: string; + chainId: number; + startDate?: Date; + endDate?: Date; +}): Promise { + const queryParams = [ + `chain=${params.chainId}`, + "group_by=time", + "group_by=topic_0 as event_signature", + "aggregate=toStartOfDay(toDate(block_timestamp)) as time", + "aggregate=count(*) as count", + params.startDate + ? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}` + : "", + params.endDate + ? `filter_block_timestamp_lte=${getUnixTime(params.endDate)}` + : "", + ] + .filter(Boolean) + .join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const json = (await res.json()) as InsightResponse; + const aggregations = Object.values(json.aggregations[0]); + + const collectedAggregations: InsightAggregationEntry[] = []; + for (const value of aggregations) { + if ( + typeof value === "object" && + value !== null && + "time" in value && + "count" in value && + "event_signature" in value && + typeof value.event_signature === "string" && + typeof value.time === "string" && + typeof value.count === "number" + ) { + collectedAggregations.push({ + count: value.count, + time: value.time, + event_signature: value.event_signature, + }); + } + } + + const dayToFunctionBreakdownMap: Map< + string, + Record + > = new Map(); + + for (const value of collectedAggregations) { + const mapKey = value.time; + let valueForDay = dayToFunctionBreakdownMap.get(mapKey); + if (!valueForDay) { + valueForDay = {}; + dayToFunctionBreakdownMap.set(mapKey, valueForDay); + } + + valueForDay[value.event_signature] = + (valueForDay[value.event_signature] || 0) + value.count; + } + + const values: EventBreakdownEntry[] = []; + + for (const [day, value] of dayToFunctionBreakdownMap.entries()) { + values.push({ + time: new Date(day), + ...value, + } as EventBreakdownEntry); + } + + return values.sort((a, b) => { + return new Date(a.time).getTime() - new Date(b.time).getTime(); + }); +} diff --git a/apps/dashboard/src/data/analytics/contract-events.ts b/apps/dashboard/src/data/analytics/contract-events.ts new file mode 100644 index 00000000000..19029e8c621 --- /dev/null +++ b/apps/dashboard/src/data/analytics/contract-events.ts @@ -0,0 +1,83 @@ +import { getUnixTime } from "date-fns"; +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/env"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [ + Record< + string, + | { + time: string; + count: number; + } + | unknown + >, + ]; +}; + +type AnalyticsEntry = { + count: number; + time: Date; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getContractEventAnalytics(params: { + contractAddress: string; + chainId: number; + startDate?: Date; + endDate?: Date; +}): Promise { + const queryParams = [ + `chain=${params.chainId}`, + "group_by=time", + "aggregate=toStartOfDay(toDate(block_timestamp)) as time", + "aggregate=count(block_timestamp) as count", + params.startDate + ? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}` + : "", + params.endDate + ? `filter_block_timestamp_lte=${getUnixTime(params.endDate)}` + : "", + ] + .filter(Boolean) + .join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const json = (await res.json()) as InsightResponse; + const aggregations = Object.values(json.aggregations[0]); + + const values: AnalyticsEntry[] = []; + + for (const value of aggregations) { + if ( + typeof value === "object" && + value !== null && + "time" in value && + "count" in value && + typeof value.time === "string" && + typeof value.count === "number" + ) { + values.push({ + count: value.count, + time: new Date(value.time), + }); + } + } + + return values.sort((a, b) => a.time.getTime() - b.time.getTime()); +} diff --git a/apps/dashboard/src/data/analytics/contract-function-breakdown.ts b/apps/dashboard/src/data/analytics/contract-function-breakdown.ts new file mode 100644 index 00000000000..feca2b6ae78 --- /dev/null +++ b/apps/dashboard/src/data/analytics/contract-function-breakdown.ts @@ -0,0 +1,109 @@ +import { getUnixTime } from "date-fns"; +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/env"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +type InsightAggregationEntry = { + function_selector: string; + time: string; + count: number; +}; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [Record]; +}; + +type FunctionBreakdownEntry = Record & { + time: Date; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getContractFunctionBreakdown(params: { + contractAddress: string; + chainId: number; + startDate?: Date; + endDate?: Date; +}): Promise { + const queryParams = [ + `chain=${params.chainId}`, + "group_by=time", + "group_by=function_selector", + "aggregate=toStartOfDay(toDate(block_timestamp)) as time", + "aggregate=count(*) as count", + params.startDate + ? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}` + : "", + params.endDate + ? `filter_block_timestamp_lte=${getUnixTime(params.endDate)}` + : "", + ] + .filter(Boolean) + .join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const dayToFunctionSelectorToCount: Map< + string, + Record + > = new Map(); + + const json = (await res.json()) as InsightResponse; + const aggregations = Object.values(json.aggregations[0]); + const collectedAggregations: InsightAggregationEntry[] = []; + + for (const value of aggregations) { + if ( + typeof value === "object" && + value !== null && + "time" in value && + "count" in value && + "function_selector" in value && + typeof value.function_selector === "string" && + typeof value.time === "string" && + typeof value.count === "number" + ) { + collectedAggregations.push({ + count: value.count, + time: value.time, + function_selector: value.function_selector, + }); + } + } + + for (const value of collectedAggregations) { + const mapKey = value.time; + let valueForDay = dayToFunctionSelectorToCount.get(mapKey); + if (!valueForDay) { + valueForDay = {}; + dayToFunctionSelectorToCount.set(mapKey, valueForDay); + } + + valueForDay[value.function_selector] = + (valueForDay[value.function_selector] || 0) + value.count; + } + + const values: FunctionBreakdownEntry[] = []; + for (const [day, value] of dayToFunctionSelectorToCount.entries()) { + values.push({ + time: new Date(day), + ...value, + } as FunctionBreakdownEntry); + } + + return values.sort((a, b) => { + return new Date(a.time).getTime() - new Date(b.time).getTime(); + }); +} diff --git a/apps/dashboard/src/data/analytics/contract-transactions.ts b/apps/dashboard/src/data/analytics/contract-transactions.ts new file mode 100644 index 00000000000..8cba57d7d3c --- /dev/null +++ b/apps/dashboard/src/data/analytics/contract-transactions.ts @@ -0,0 +1,83 @@ +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "@/constants/env"; +import { getUnixTime } from "date-fns"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [ + Record< + string, + | { + time: string; + count: number; + } + | unknown + >, + ]; +}; + +type TransactionAnalyticsEntry = { + count: number; + time: Date; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getContractTransactionAnalytics(params: { + contractAddress: string; + chainId: number; + startDate?: Date; + endDate?: Date; +}): Promise { + const queryParams = [ + `chain=${params.chainId}`, + "group_by=time", + "aggregate=toStartOfDay(toDate(block_timestamp)) as time", + "aggregate=count(block_timestamp) as count", + params.startDate + ? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}` + : "", + params.endDate + ? `filter_block_timestamp_lte=${getUnixTime(params.endDate)}` + : "", + ] + .filter(Boolean) + .join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const json = (await res.json()) as InsightResponse; + const aggregations = Object.values(json.aggregations[0]); + + const returnValue: TransactionAnalyticsEntry[] = []; + + for (const tx of aggregations) { + if ( + typeof tx === "object" && + tx !== null && + "time" in tx && + "count" in tx && + typeof tx.time === "string" && + typeof tx.count === "number" + ) { + returnValue.push({ + count: tx.count, + time: new Date(tx.time), + }); + } + } + + return returnValue.sort((a, b) => a.time.getTime() - b.time.getTime()); +} diff --git a/apps/dashboard/src/data/analytics/contract-wallet-analytics.ts b/apps/dashboard/src/data/analytics/contract-wallet-analytics.ts new file mode 100644 index 00000000000..869a8e3c1cb --- /dev/null +++ b/apps/dashboard/src/data/analytics/contract-wallet-analytics.ts @@ -0,0 +1,83 @@ +import { getUnixTime } from "date-fns"; +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/env"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [ + Record< + string, + | { + time: string; + count: number; + } + | unknown + >, + ]; +}; + +type TransactionAnalyticsEntry = { + count: number; + time: Date; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getContractUniqueWalletAnalytics(params: { + contractAddress: string; + chainId: number; + startDate?: Date; + endDate?: Date; +}): Promise { + const queryParams = [ + `chain=${params.chainId}`, + "group_by=time", + "aggregate=toStartOfDay(toDate(block_timestamp)) as time", + "aggregate=count(distinct from_address) as count", + params.startDate + ? `filter_block_timestamp_gte=${getUnixTime(params.startDate)}` + : "", + params.endDate + ? `filter_block_timestamp_lte=${getUnixTime(params.endDate)}` + : "", + ] + .filter(Boolean) + .join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const json = (await res.json()) as InsightResponse; + const aggregations = Object.values(json.aggregations[0]); + + const returnValue: TransactionAnalyticsEntry[] = []; + + for (const tx of aggregations) { + if ( + typeof tx === "object" && + tx !== null && + "time" in tx && + "count" in tx && + typeof tx.time === "string" && + typeof tx.count === "number" + ) { + returnValue.push({ + count: tx.count, + time: new Date(tx.time), + }); + } + } + + return returnValue.sort((a, b) => a.time.getTime() - b.time.getTime()); +} diff --git a/apps/dashboard/src/data/analytics/hooks.ts b/apps/dashboard/src/data/analytics/hooks.ts index caa216a21df..6710da57a03 100644 --- a/apps/dashboard/src/data/analytics/hooks.ts +++ b/apps/dashboard/src/data/analytics/hooks.ts @@ -1,66 +1,42 @@ import { useQuery } from "@tanstack/react-query"; +import { getContractEventBreakdown } from "./contract-event-breakdown"; +import { getContractEventAnalytics } from "./contract-events"; +import { getContractFunctionBreakdown } from "./contract-function-breakdown"; +import { getContractTransactionAnalytics } from "./contract-transactions"; +import { getContractUniqueWalletAnalytics } from "./contract-wallet-analytics"; +import { getTotalContractEvents } from "./total-contract-events"; +import { getTotalContractTransactions } from "./total-contract-transactions"; +import { getTotalContractUniqueWallets } from "./total-unique-wallets"; export type AnalyticsQueryParams = { chainId: number; contractAddress: string; startDate?: Date; endDate?: Date; - interval?: "minute" | "hour" | "day" | "week" | "month"; }; -async function makeQuery( - path: string, - query: Record, -) { - const queryString = `?${Object.entries(query) - .filter(([, value]) => !!value) - .map(([key, value]) => `${key}=${value}`) - .join("&")}`; - return fetch(`/api/server-proxy/chainsaw/${path}${queryString}`, { - method: "GET", - }); -} - -type TransactionQueryResult = { - count: number; - time: string; -}; - -async function getTransactionAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/transactions", { - chainId: params.chainId, - contractAddress: params.contractAddress, - startDate: params.startDate?.toString(), - endDate: params.endDate?.toString(), - interval: params.interval, - }); - - const { results } = await res.json(); - // biome-ignore lint/suspicious/noExplicitAny: FIXME - return results.map((item: any) => ({ - count: Number.parseInt(item.cnt), - time: new Date(item.time).getTime(), - })); -} +export function useContractTransactionAnalytics(params: { + chainId: number; + contractAddress: string; + endDate?: Date; + startDate?: Date; +}) { + const { startDate, endDate, contractAddress, chainId } = params; -export function useTransactionAnalytics(params: AnalyticsQueryParams) { return useQuery({ queryKey: [ "analytics", "transactions", { - contractAddress: params.contractAddress, - chainId: params.chainId, - startDate: `${params.startDate?.getDate()}-${params.startDate?.getMonth()}-${params.startDate?.getFullYear()}`, - endDate: `${params.endDate?.getDate()}-${params.endDate?.getMonth()}-${params.endDate?.getFullYear()}`, + contractAddress: contractAddress, + chainId: chainId, + startDate: getDayKey(startDate), + endDate: getDayKey(endDate), }, ] as const, queryFn: async () => { - return await getTransactionAnalytics(params); + return getContractTransactionAnalytics(params); }, - enabled: !!params.contractAddress && !!params.chainId, }); } @@ -68,21 +44,10 @@ export type TotalQueryResult = { count: number; }; -async function getTotalTransactionAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/transactions/total", { - chainId: params.chainId, - contractAddress: params.contractAddress, - }); - - const { results } = await res.json(); - return { - count: Number.parseInt(results[0].cnt), - }; -} - -export function useTotalTransactionAnalytics(params: AnalyticsQueryParams) { +export function useTotalContractTransactionAnalytics(params: { + chainId: number; + contractAddress: string; +}) { return useQuery({ queryKey: [ "analytics", @@ -90,36 +55,23 @@ export function useTotalTransactionAnalytics(params: AnalyticsQueryParams) { { contractAddress: params.contractAddress, chainId: params.chainId, - currentDate: new Date().toISOString().split("T")[0], + currentDate: getDayKey(new Date()), }, ] as const, queryFn: async () => { - return await getTotalTransactionAnalytics(params); + return getTotalContractTransactions(params); }, - enabled: !!params.contractAddress && !!params.chainId, }); } -async function getLogsAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/logs", { - chainId: params.chainId, - contractAddress: params.contractAddress, - startDate: params.startDate?.toString(), - endDate: params.endDate?.toString(), - interval: params.interval, - }); - - const { results } = await res.json(); - // biome-ignore lint/suspicious/noExplicitAny: FIXME - return results.map((item: any) => ({ - count: Number.parseInt(item.cnt), - time: new Date(item.time).getTime(), - })); +function getDayKey(date?: Date) { + if (!date) { + return ""; + } + return date.toISOString().split("T")[0]; } -export function useLogsAnalytics(params: AnalyticsQueryParams) { +export function useContractEventAnalytics(params: AnalyticsQueryParams) { return useQuery({ queryKey: [ "analytics", @@ -127,32 +79,18 @@ export function useLogsAnalytics(params: AnalyticsQueryParams) { { contractAddress: params.contractAddress, chainId: params.chainId, - startDate: `${params.startDate?.getDate()}-${params.startDate?.getMonth()}-${params.startDate?.getFullYear()}`, - endDate: `${params.endDate?.getDate()}-${params.endDate?.getMonth()}-${params.endDate?.getFullYear()}`, + startDate: getDayKey(params.startDate), + endDate: getDayKey(params.endDate), }, ] as const, queryFn: async () => { - return await getLogsAnalytics(params); + return await getContractEventAnalytics(params); }, enabled: !!params.contractAddress && !!params.chainId, }); } -async function getTotalLogsAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/logs/total", { - chainId: params.chainId, - contractAddress: params.contractAddress, - }); - - const { results } = await res.json(); - return { - count: Number.parseInt(results[0].cnt), - }; -} - -export function useTotalLogsAnalytics(params: AnalyticsQueryParams) { +export function useTotalContractEvents(params: AnalyticsQueryParams) { return useQuery({ queryKey: [ "analytics", @@ -160,62 +98,16 @@ export function useTotalLogsAnalytics(params: AnalyticsQueryParams) { { contractAddress: params.contractAddress, chainId: params.chainId, - currentDate: new Date().toISOString().split("T")[0], + currentDate: getDayKey(new Date()), }, ] as const, queryFn: async () => { - return await getTotalLogsAnalytics(params); + return getTotalContractEvents(params); }, - enabled: !!params.contractAddress && !!params.chainId, }); } -type FunctionsQueryResponse = { - function_name: string; - cnt: string; - time: string; -}; - -type FunctionsQueryResult = { - time: string; - // biome-ignore lint/suspicious/noExplicitAny: FIXME - [key: string]: any; -}; - -async function getFunctionsAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/functions", { - chainId: params.chainId, - contractAddress: params.contractAddress, - startDate: params.startDate?.toString(), - endDate: params.endDate?.toString(), - interval: params.interval, - }); - - const { results } = await res.json(); - const callsByTime = (results as FunctionsQueryResponse[]).reduce( - (acc, item) => { - const time = new Date(item.time).getTime(); - if (!acc[time]) { - acc[time] = { - [item.function_name]: Number.parseInt(item.cnt), - }; - } else { - acc[time][item.function_name] = Number.parseInt(item.cnt); - } - return acc; - }, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - {} as Record, - ); - - return Object.keys(callsByTime).map((time) => { - return { time: Number.parseInt(time), ...callsByTime[time] }; - }); -} - -export function useFunctionsAnalytics(params: AnalyticsQueryParams) { +export function useContractFunctionBreakdown(params: AnalyticsQueryParams) { return useQuery({ queryKey: [ "analytics", @@ -228,58 +120,13 @@ export function useFunctionsAnalytics(params: AnalyticsQueryParams) { }, ] as const, queryFn: () => { - return getFunctionsAnalytics(params); + return getContractFunctionBreakdown(params); }, enabled: !!params.contractAddress && !!params.chainId, }); } -type EventsQueryResponse = { - event_name: string; - cnt: string; - time: string; -}; - -type EventsQueryResult = { - time: string; - // biome-ignore lint/suspicious/noExplicitAny: FIXME - [key: string]: any; -}; - -async function getEventsAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/events", { - chainId: params.chainId, - contractAddress: params.contractAddress, - startDate: params.startDate?.toString(), - endDate: params.endDate?.toString(), - interval: params.interval, - }); - - const { results } = await res.json(); - const callsByTime = (results as EventsQueryResponse[]).reduce( - (acc, item) => { - const time = new Date(item.time).getTime(); - if (!acc[time]) { - acc[time] = { - [item.event_name]: Number.parseInt(item.cnt), - }; - } else { - acc[time][item.event_name] = Number.parseInt(item.cnt); - } - return acc; - }, - // biome-ignore lint/suspicious/noExplicitAny: FIXME - {} as Record, - ); - - return Object.keys(callsByTime).map((time) => { - return { time: Number.parseInt(time), ...callsByTime[time] }; - }); -} - -export function useEventsAnalytics(params: AnalyticsQueryParams) { +export function useContractEventBreakdown(params: AnalyticsQueryParams) { return useQuery({ queryKey: [ "analytics", @@ -292,37 +139,13 @@ export function useEventsAnalytics(params: AnalyticsQueryParams) { }, ] as const, queryFn: () => { - return getEventsAnalytics(params); + return getContractEventBreakdown(params); }, enabled: !!params.contractAddress && !!params.chainId, }); } -type WalletsQueryResult = { - wallets: number; - time: string; -}; - -async function getUniqueWalletsAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/wallets/active", { - chainId: params.chainId, - contractAddress: params.contractAddress, - startDate: params.startDate?.toString(), - endDate: params.endDate?.toString(), - interval: params.interval, - }); - - const { results } = await res.json(); - // biome-ignore lint/suspicious/noExplicitAny: FIXME - return results.map((item: any) => ({ - wallets: Number.parseInt(item.active_wallets), - time: new Date(item.time).getTime(), - })); -} - -export function useUniqueWalletsAnalytics(params: AnalyticsQueryParams) { +export function useContractUniqueWalletAnalytics(params: AnalyticsQueryParams) { return useQuery({ queryKey: [ "analytics", @@ -330,32 +153,15 @@ export function useUniqueWalletsAnalytics(params: AnalyticsQueryParams) { { contractAddress: params.contractAddress, chainId: params.chainId, - startDate: `${params.startDate?.getDate()}-${params.startDate?.getMonth()}-${params.startDate?.getFullYear()}`, - endDate: `${params.endDate?.getDate()}-${params.endDate?.getMonth()}-${params.endDate?.getFullYear()}`, + startDate: getDayKey(params.startDate), + endDate: getDayKey(params.endDate), }, - ] as const, - queryFn: () => { - return getUniqueWalletsAnalytics(params); - }, - enabled: !!params.contractAddress && !!params.chainId, + ], + queryFn: async () => getContractUniqueWalletAnalytics(params), }); } -async function getTotalWalletsAnalytics( - params: AnalyticsQueryParams, -): Promise { - const res = await makeQuery("/wallets/total", { - chainId: params.chainId, - contractAddress: params.contractAddress, - }); - - const { results } = await res.json(); - return { - count: Number.parseInt(results[0].cnt), - }; -} - -export function useTotalWalletsAnalytics(params: AnalyticsQueryParams) { +export function useTotalContractUniqueWallets(params: AnalyticsQueryParams) { return useQuery({ queryKey: [ "analytics", @@ -363,12 +169,11 @@ export function useTotalWalletsAnalytics(params: AnalyticsQueryParams) { { contractAddress: params.contractAddress, chainId: params.chainId, - currentDate: new Date().toISOString().split("T")[0], + currentDate: getDayKey(new Date()), }, ] as const, queryFn: () => { - return getTotalWalletsAnalytics(params); + return getTotalContractUniqueWallets(params); }, - enabled: !!params.contractAddress && !!params.chainId, }); } diff --git a/apps/dashboard/src/data/analytics/total-contract-events.ts b/apps/dashboard/src/data/analytics/total-contract-events.ts new file mode 100644 index 00000000000..5cda9a5d022 --- /dev/null +++ b/apps/dashboard/src/data/analytics/total-contract-events.ts @@ -0,0 +1,45 @@ +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "@/constants/env"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [ + { + 0: { + total: number; + }; + }, + ]; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getTotalContractEvents(params: { + contractAddress: string; + chainId: number; +}): Promise<{ count: number }> { + const queryParams = [ + `chain=${params.chainId}`, + "aggregate=count(block_number) as total", + ].join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/events/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const json = (await res.json()) as InsightResponse; + + return { + count: json.aggregations[0][0].total, + }; +} diff --git a/apps/dashboard/src/data/analytics/total-contract-transactions.ts b/apps/dashboard/src/data/analytics/total-contract-transactions.ts new file mode 100644 index 00000000000..de9aa0ef87e --- /dev/null +++ b/apps/dashboard/src/data/analytics/total-contract-transactions.ts @@ -0,0 +1,45 @@ +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "../../@/constants/env"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [ + { + 0: { + total: number; + }; + }, + ]; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getTotalContractTransactions(params: { + contractAddress: string; + chainId: number; +}): Promise<{ count: number }> { + const queryParams = [ + `chain=${params.chainId}`, + "aggregate=count(block_number) as total", + ].join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const json = (await res.json()) as InsightResponse; + + return { + count: json.aggregations[0][0].total, + }; +} diff --git a/apps/dashboard/src/data/analytics/total-unique-wallets.ts b/apps/dashboard/src/data/analytics/total-unique-wallets.ts new file mode 100644 index 00000000000..67e0872a850 --- /dev/null +++ b/apps/dashboard/src/data/analytics/total-unique-wallets.ts @@ -0,0 +1,45 @@ +import { DASHBOARD_THIRDWEB_CLIENT_ID } from "@/constants/env"; +import { getVercelEnv } from "../../lib/vercel-utils"; + +// This is weird aggregation response type, this will be changed later in insight +type InsightResponse = { + aggregations: [ + { + 0: { + total: number; + }; + }, + ]; +}; + +const thirdwebDomain = + getVercelEnv() !== "production" ? "thirdweb-dev" : "thirdweb"; + +export async function getTotalContractUniqueWallets(params: { + contractAddress: string; + chainId: number; +}): Promise<{ count: number }> { + const queryParams = [ + `chain=${params.chainId}`, + "aggregate=count(distinct from_address) as total", + ].join("&"); + + const res = await fetch( + `https://insight.${thirdwebDomain}.com/v1/transactions/${params.contractAddress}?${queryParams}`, + { + headers: { + "x-client-id": DASHBOARD_THIRDWEB_CLIENT_ID, + }, + }, + ); + + if (!res.ok) { + throw new Error("Failed to fetch analytics data"); + } + + const json = (await res.json()) as InsightResponse; + + return { + count: json.aggregations[0][0].total, + }; +} diff --git a/apps/dashboard/src/pages/api/server-proxy/chainsaw/[...paths].tsx b/apps/dashboard/src/pages/api/server-proxy/chainsaw/[...paths].tsx deleted file mode 100644 index c8658aa389a..00000000000 --- a/apps/dashboard/src/pages/api/server-proxy/chainsaw/[...paths].tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { isProd } from "@/constants/env"; -import type { NextRequest } from "next/server"; - -export const config = { - runtime: "edge", -}; -const handler = async (req: NextRequest) => { - if (!process.env.CHAINSAW_API_KEY) { - return new Response("CHAINSAW_API_KEY not set", { status: 401 }); - } - - // get the full path from the request including query params - let pathname = req.nextUrl.pathname; - - // remove the /api/server-proxy prefix - pathname = pathname.replace(/^\/api\/server-proxy\/chainsaw/, ""); - - const searchParams = req.nextUrl.searchParams; - searchParams.delete("paths"); - - // create a new URL object for the chainsaw service - const CHAINSAW_URL = new URL( - `https://chainsaw.${isProd ? "thirdweb" : "thirdweb-dev"}.com`, - ); - CHAINSAW_URL.pathname = pathname; - searchParams.forEach((value, key) => { - CHAINSAW_URL.searchParams.append(key, value); - }); - - return fetch(CHAINSAW_URL, { - method: req.method, - headers: { - "content-type": "application/json", - // pass the shared secret - "x-service-api-key": process.env.CHAINSAW_API_KEY, - }, - body: req.body, - }); -}; - -export default handler; diff --git a/packages/thirdweb/src/exports/utils.ts b/packages/thirdweb/src/exports/utils.ts index 8a5491d8dec..4d265e549bb 100644 --- a/packages/thirdweb/src/exports/utils.ts +++ b/packages/thirdweb/src/exports/utils.ts @@ -195,6 +195,7 @@ export { parseAbiParams } from "../utils/contract/parse-abi-params.js"; export { max, min } from "../utils/bigint.js"; export { toFunctionSelector } from "viem"; +export { toEventSelector } from "viem"; export type { Abi, AbiFunction, diff --git a/turbo.json b/turbo.json index d6ba6d2034f..a3f1de415ef 100644 --- a/turbo.json +++ b/turbo.json @@ -48,7 +48,8 @@ "TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY", "NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET", - "NEXT_PUBLIC_NEBULA_URL" + "NEXT_PUBLIC_NEBULA_URL", + "INSIGHT_SERVICE_API_KEY" ], "outputs": [".next/**", "!.next/cache/**"], "dependsOn": ["^build"]