From 1983edb791b17d305499c50b6dee791acdb6bcf8 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 18 Jun 2026 11:50:50 -0700 Subject: [PATCH 1/5] feat(web): record service ping history and add usage report download Record each service ping in a new ServicePingEvent table (activation code stripped) so offline deployments, which can't report usage to Lighthouse automatically, can download their usage history and email it to us. Adds a "Download usage report" button to the offline license settings card that exports the recorded pings as a JSON file. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../migration.sql | 8 +++ packages/db/prisma/schema.prisma | 6 ++ .../src/app/(app)/settings/license/actions.ts | 29 +++++++++ .../downloadServicePingHistoryButton.tsx | 65 +++++++++++++++++++ .../settings/license/offlineLicenseCard.tsx | 60 ++++++++++------- .../web/src/features/billing/servicePing.ts | 21 ++++++ 6 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 packages/db/prisma/migrations/20260618182127_add_service_ping_event_table/migration.sql create mode 100644 packages/web/src/app/(app)/settings/license/actions.ts create mode 100644 packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx diff --git a/packages/db/prisma/migrations/20260618182127_add_service_ping_event_table/migration.sql b/packages/db/prisma/migrations/20260618182127_add_service_ping_event_table/migration.sql new file mode 100644 index 000000000..97b47bc85 --- /dev/null +++ b/packages/db/prisma/migrations/20260618182127_add_service_ping_event_table/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ServicePingEvent" ( + "id" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ServicePingEvent_pkey" PRIMARY KEY ("id") +); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 415b0d3d1..738f0817d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -358,6 +358,12 @@ model License { updatedAt DateTime @updatedAt } +model ServicePingEvent { + id String @id @default(cuid()) + payload Json + createdAt DateTime @default(now()) +} + enum OrgRole { OWNER MEMBER diff --git a/packages/web/src/app/(app)/settings/license/actions.ts b/packages/web/src/app/(app)/settings/license/actions.ts new file mode 100644 index 000000000..8ff962643 --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/actions.ts @@ -0,0 +1,29 @@ +'use server'; + +import { sew } from "@/middleware/sew"; +import { withAuth } from "@/middleware/withAuth"; +import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole"; +import { OrgRole } from "@sourcebot/db"; +import { ServiceError } from "@/lib/serviceError"; + +export interface ServicePingHistoryEntry { + createdAt: string; + payload: unknown; +} + +// Returns the recorded Service Ping history so offline deployments can export +// it and send it back to us out-of-band (they can't reach Lighthouse directly). +export const getServicePingHistory = async (): Promise => sew(() => + withAuth(async ({ role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + const events = await prisma.servicePingEvent.findMany({ + orderBy: { createdAt: 'asc' }, + }); + + return events.map((event) => ({ + createdAt: event.createdAt.toISOString(), + payload: event.payload, + })); + }) + ) +); diff --git a/packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx b/packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx new file mode 100644 index 000000000..fa2fcf259 --- /dev/null +++ b/packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useCallback, useState } from "react"; +import { Download, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/hooks/use-toast"; +import { isServiceError } from "@/lib/utils"; +import { getServicePingHistory } from "./actions"; + +export function DownloadServicePingHistoryButton() { + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleDownload = useCallback(async () => { + setIsLoading(true); + try { + const result = await getServicePingHistory(); + + if (isServiceError(result)) { + toast({ + description: "Failed to export service ping history. Please try again.", + variant: "destructive", + }); + return; + } + + if (result.length === 0) { + toast({ + description: "No service ping history has been recorded yet.", + }); + return; + } + + const blob = new Blob([JSON.stringify(result, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${new Date().toISOString().slice(0, 10)}-usage-history.json`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + } finally { + setIsLoading(false); + } + }, [toast]); + + return ( + + ); +} diff --git a/packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx b/packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx index 81707689e..3768d073f 100644 --- a/packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx +++ b/packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx @@ -1,6 +1,7 @@ import type { OfflineLicenseMetadata } from "@sourcebot/shared"; import { Badge } from "@/components/ui/badge"; import { SettingsCard } from "../components/settingsCard"; +import { DownloadServicePingHistoryButton } from "./downloadServicePingHistoryButton"; interface OfflineLicenseCardProps { license: OfflineLicenseMetadata; @@ -15,34 +16,47 @@ export function OfflineLicenseCard({ license, isExpired }: OfflineLicenseCardPro return ( -
-
-
-

Enterprise plan

- {isExpired && ( - - Expired - - )} -
-
- {truncatedId} +
+
+
+
+

Enterprise plan

+ {isExpired && ( + + Expired + + )} +
+
+ {truncatedId} +
-
-
- {license.seats !== undefined && ( +
+ {license.seats !== undefined && ( +
+

Billed seats

+

{license.seats}

+
+ )}
-

Billed seats

-

{license.seats}

+

+ {isExpired ? "Expired on" : "Expires on"} +

+

{formatDate(expiryDate)}

- )} -
-

- {isExpired ? "Expired on" : "Expires on"} -

-

{formatDate(expiryDate)}

+
+

+ Your instance doesn't report usage automatically. Usage data must be + manually sent to{" "} + + ar@sourcebot.dev + + . +

+ +
); diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index e8d9d9561..ad4a91521 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -83,6 +83,8 @@ export const syncWithLighthouse = async (orgId: number) => { ...(activationCode && { activationCode }), }; + await recordServicePingInDB(payload); + const response = await client.ping(payload); if (isServiceError(response)) { logger.error(`Service ping failed:\n ${JSON.stringify(response, null, 2)}`) @@ -172,3 +174,22 @@ const inferDeploymentType = (): string => { } return 'other'; }; + +const recordServicePingInDB = async (payload: ServicePingRequest) => { + // Strip the activation code before persisting. This history is meant to be + // exported and sent back to us by offline deployments, so it should not + // contain the instance's secret activation code. + const { activationCode: _activationCode, ...sanitizedPayload } = payload; + + try { + await __unsafePrisma.servicePingEvent.create({ + data: { + payload: sanitizedPayload, + }, + }); + } catch (error) { + // Recording the ping is best-effort: a failure here must not prevent + // the actual ping from being sent to Lighthouse. + logger.error(`Failed to record service ping in database:\n ${error}`); + } +}; \ No newline at end of file From ec50d01591bba705354b1e77514b5526f8093e98 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 18 Jun 2026 11:51:19 -0700 Subject: [PATCH 2/5] docs: add CHANGELOG entry for service ping history [#1348] Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2dd6795c..f887cba30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Recorded service ping history locally and added a "Download usage report" button to the offline license settings page, so offline deployments can export their usage and send it to us. [#1348](https://github.com/sourcebot-dev/sourcebot/pull/1348) + ### Fixed - Upgraded `@grpc/grpc-js` to `^1.14.4`. [#1315](https://github.com/sourcebot-dev/sourcebot/pull/1315) - Upgraded `vite` to `^8.0.16`. [#1313](https://github.com/sourcebot-dev/sourcebot/pull/1313) From 5c5271e09aba86505201ffc0db29cb7f07d1c0da Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 18 Jun 2026 11:51:45 -0700 Subject: [PATCH 3/5] nit --- packages/web/src/features/billing/servicePing.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index ad4a91521..c6dbde3c1 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -176,9 +176,7 @@ const inferDeploymentType = (): string => { }; const recordServicePingInDB = async (payload: ServicePingRequest) => { - // Strip the activation code before persisting. This history is meant to be - // exported and sent back to us by offline deployments, so it should not - // contain the instance's secret activation code. + // Strip the activation code before persisting. const { activationCode: _activationCode, ...sanitizedPayload } = payload; try { From 222b07ad4f63b0d0ae71aa939e308bb3abdb8312 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 18 Jun 2026 14:22:49 -0700 Subject: [PATCH 4/5] feedback --- .../migration.sql | 4 ++++ packages/db/prisma/schema.prisma | 4 ++++ packages/shared/src/entitlements.ts | 12 ++++++++++-- packages/shared/src/index.server.ts | 2 ++ .../src/app/(app)/settings/license/actions.ts | 3 ++- .../web/src/features/billing/servicePing.ts | 18 +++++++++++++++--- 6 files changed, 37 insertions(+), 6 deletions(-) rename packages/db/prisma/migrations/{20260618182127_add_service_ping_event_table => 20260618210032_add_service_ping_event_table}/migration.sql (52%) diff --git a/packages/db/prisma/migrations/20260618182127_add_service_ping_event_table/migration.sql b/packages/db/prisma/migrations/20260618210032_add_service_ping_event_table/migration.sql similarity index 52% rename from packages/db/prisma/migrations/20260618182127_add_service_ping_event_table/migration.sql rename to packages/db/prisma/migrations/20260618210032_add_service_ping_event_table/migration.sql index 97b47bc85..954401963 100644 --- a/packages/db/prisma/migrations/20260618182127_add_service_ping_event_table/migration.sql +++ b/packages/db/prisma/migrations/20260618210032_add_service_ping_event_table/migration.sql @@ -3,6 +3,10 @@ CREATE TABLE "ServicePingEvent" ( "id" TEXT NOT NULL, "payload" JSONB NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "orgId" INTEGER NOT NULL, CONSTRAINT "ServicePingEvent_pkey" PRIMARY KEY ("id") ); + +-- AddForeignKey +ALTER TABLE "ServicePingEvent" ADD CONSTRAINT "ServicePingEvent_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 738f0817d..54444bbe2 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -318,6 +318,7 @@ model Org { mcpServers McpServer[] license License? + servicePingEvents ServicePingEvent[] } model License { @@ -362,6 +363,9 @@ model ServicePingEvent { id String @id @default(cuid()) payload Json createdAt DateTime @default(now()) + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int } enum OrgRole { diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index bcfdac6cd..93595f125 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -120,10 +120,18 @@ const getValidOnlineLicense = (_license: License | null): License | null => { return null; } +export const isValidOfflineLicenseActive = (): boolean => { + return getValidOfflineLicense() !== null; +} + +export const isValidOnlineLicenseActive = (_license: License | null): boolean => { + return getValidOnlineLicense(_license) !== null; +} + export const isValidLicenseActive = (_license: License | null): boolean => { return ( - getValidOfflineLicense() !== null || - getValidOnlineLicense(_license) !== null + isValidOfflineLicenseActive() || + isValidOnlineLicenseActive(_license) ); } diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 0c8f281a4..6a09b9340 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -6,6 +6,8 @@ export { getEntitlements as _getEntitlements, isAnonymousAccessAvailable as _isAnonymousAccessAvailable, isValidLicenseActive as _isValidLicenseActive, + isValidOfflineLicenseActive, + isValidOnlineLicenseActive as _isValidOnlineLicenseActive, getSeatCap, getOfflineLicenseMetadata, STALE_ONLINE_LICENSE_THRESHOLD_MS, diff --git a/packages/web/src/app/(app)/settings/license/actions.ts b/packages/web/src/app/(app)/settings/license/actions.ts index 8ff962643..a58636b95 100644 --- a/packages/web/src/app/(app)/settings/license/actions.ts +++ b/packages/web/src/app/(app)/settings/license/actions.ts @@ -14,9 +14,10 @@ export interface ServicePingHistoryEntry { // Returns the recorded Service Ping history so offline deployments can export // it and send it back to us out-of-band (they can't reach Lighthouse directly). export const getServicePingHistory = async (): Promise => sew(() => - withAuth(async ({ role, prisma }) => + withAuth(async ({ org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { const events = await prisma.servicePingEvent.findMany({ + where: { orgId: org.id }, orderBy: { createdAt: 'asc' }, }); diff --git a/packages/web/src/features/billing/servicePing.ts b/packages/web/src/features/billing/servicePing.ts index c6dbde3c1..e6b052766 100644 --- a/packages/web/src/features/billing/servicePing.ts +++ b/packages/web/src/features/billing/servicePing.ts @@ -2,7 +2,13 @@ import { existsSync } from "fs"; import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; import { isServiceError } from "@/lib/utils"; import { __unsafePrisma } from "@/prisma"; -import { createLogger, decryptActivationCode, env, SOURCEBOT_VERSION } from "@sourcebot/shared"; +import { + createLogger, + decryptActivationCode, + env, + SOURCEBOT_VERSION, + isValidOfflineLicenseActive +} from "@sourcebot/shared"; import { client } from "./client"; import { ServicePingRequest } from "./types"; import { ServiceErrorException } from "@/lib/serviceError"; @@ -83,7 +89,12 @@ export const syncWithLighthouse = async (orgId: number) => { ...(activationCode && { activationCode }), }; - await recordServicePingInDB(payload); + await recordServicePingInDB(orgId, payload); + + if (isValidOfflineLicenseActive()) { + logger.debug('Skipping service ping: active offline license detected.'); + return; + } const response = await client.ping(payload); if (isServiceError(response)) { @@ -175,13 +186,14 @@ const inferDeploymentType = (): string => { return 'other'; }; -const recordServicePingInDB = async (payload: ServicePingRequest) => { +const recordServicePingInDB = async (orgId: number, payload: ServicePingRequest) => { // Strip the activation code before persisting. const { activationCode: _activationCode, ...sanitizedPayload } = payload; try { await __unsafePrisma.servicePingEvent.create({ data: { + orgId, payload: sanitizedPayload, }, }); From e8bbbaad62969a80a4ec136fa128ffe56f5a6e52 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 18 Jun 2026 14:26:32 -0700 Subject: [PATCH 5/5] feedback --- .../license/downloadServicePingHistoryButton.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx b/packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx index fa2fcf259..e668f5b01 100644 --- a/packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx +++ b/packages/web/src/app/(app)/settings/license/downloadServicePingHistoryButton.tsx @@ -1,8 +1,8 @@ 'use client'; import { useCallback, useState } from "react"; -import { Download, Loader2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { Download } from "lucide-react"; +import { LoadingButton } from "@/components/ui/loading-button"; import { useToast } from "@/components/hooks/use-toast"; import { isServiceError } from "@/lib/utils"; import { getServicePingHistory } from "./actions"; @@ -48,18 +48,14 @@ export function DownloadServicePingHistoryButton() { }, [toast]); return ( - + ); }