diff --git a/admin/package.json b/admin/package.json
index f7edd833f..94249ac36 100644
--- a/admin/package.json
+++ b/admin/package.json
@@ -12,8 +12,9 @@
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
- "build:pages": "vite build && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
- "sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after"
+ "build:pages": "vite build && pnpm sourcemaps:maybe && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
+ "sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after",
+ "sourcemaps:maybe": "sh -c 'if [ -n \"$POSTHOG_CLI_PROJECT_ID\" ] && [ -n \"$POSTHOG_CLI_API_KEY\" ]; then pnpm sourcemaps:posthog; else echo \"no PostHog CLI credentials; skipping the source map upload\"; fi'"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
diff --git a/admin/src/app/dashboard/AuditPage.tsx b/admin/src/app/dashboard/AuditPage.tsx
index 43739d1f9..d38c785df 100644
--- a/admin/src/app/dashboard/AuditPage.tsx
+++ b/admin/src/app/dashboard/AuditPage.tsx
@@ -183,7 +183,7 @@ export default function AuditPage() {
{(data?.data ?? []).map((row) => (
))}
- {data && data.data.length === 0 && (
+ {data && !data.data?.length && (
|
No audit entries match these filters.
diff --git a/admin/src/lib/api/models/admin.ts b/admin/src/lib/api/models/admin.ts
index 1844d0268..50edf0483 100644
--- a/admin/src/lib/api/models/admin.ts
+++ b/admin/src/lib/api/models/admin.ts
@@ -129,7 +129,10 @@ export interface AdminAuditLogSearch {
}
export interface AdminAuditLogsResult {
- data: AdminAuditLog[];
+ // Nullable on purpose, like AdminUserPreview's slices: a backend older than
+ // the make-not-declare fix in pg_admin.go answers null for an empty page,
+ // and typing it as an array is what let `data.data.length` crash the page.
+ data: AdminAuditLog[] | null;
pagination: {
cursor?: string;
has_more?: boolean;
diff --git a/docs/content/docs/development/configuration.mdx b/docs/content/docs/development/configuration.mdx
index 7cd983420..e3a415573 100644
--- a/docs/content/docs/development/configuration.mdx
+++ b/docs/content/docs/development/configuration.mdx
@@ -537,12 +537,14 @@ A browser stack trace is minified without them. Uploading them is optional and o
| Build variable | Backend | What it does |
|---|---|---|
-| `POSTHOG_CLI_API_KEY`, `POSTHOG_CLI_PROJECT_ID` | PostHog | Both set makes the image build run `pnpm sourcemaps:posthog` after `vite build`, which injects a chunk id, uploads, and deletes the `.map` files it sent. The `web`, `admin` and `forms` images all do this, so a form page's stack trace is readable too |
+| `POSTHOG_CLI_API_KEY`, `POSTHOG_CLI_PROJECT_ID` | PostHog | Both set makes the build run `pnpm sourcemaps:posthog` after `vite build`, which injects a chunk id, uploads, and deletes the `.map` files it sent. The `web`, `admin` and `forms` images all do this, so a form page's stack trace is readable too |
| `POSTHOG_CLI_HOST` | PostHog | The instance to upload to, for PostHog EU or a self-hosted one |
| `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT` | Sentry | All three set puts the Sentry Vite plugin in the build |
In this repository's release workflow the two project ids are repository variables and the two keys are repository secrets, passed to the image build as build secrets. Nothing in the app has to match a release name: PostHog pairs a stack frame with its map through the chunk id the CLI injected into the served file, so an event from any build resolves as long as that build's maps were uploaded.
+A static host builds with `pnpm build:pages` instead of an image, and that runs the same upload when the same two variables are set in the host's build environment. Setting them there matters more than it looks: with no maps uploaded, PostHog falls back to fetching `.js.map` from the site itself, a static host answers that with its SPA fallback, and every frame in every issue reads `Invalid source map: bad json` beside a minified function name.
+
### Product analytics and session replay
These are the same two keys as the error tracking above, and they exist for the hosted service. A self-hosted instance that sets no key loads no analytics and sends no usage data: the installer never asks about them, and a build with none of them set contains no analytics script to block. An instance that does set a key gets everything below.
diff --git a/forms/src/FormRenderer.tsx b/forms/src/FormRenderer.tsx
index 9a3a29d47..4319982cc 100644
--- a/forms/src/FormRenderer.tsx
+++ b/forms/src/FormRenderer.tsx
@@ -17,7 +17,7 @@ import { visitorKey } from "./events";
import type { AnswerValue } from "./fields";
import { FieldControl } from "./fields";
import { Turnstile } from "./Turnstile";
-import { resetTurnstile } from "./turnstile";
+import { resetTurnstile } from "./turnstileScript";
type Answers = Record;
diff --git a/forms/src/Turnstile.tsx b/forms/src/Turnstile.tsx
index 6e3ce5238..29994be9e 100644
--- a/forms/src/Turnstile.tsx
+++ b/forms/src/Turnstile.tsx
@@ -1,10 +1,10 @@
// Cloudflare Turnstile widget, rendered explicitly so React owns the mount
-// point. resetTurnstile() (turnstile.ts) clears the used token after a
+// point. resetTurnstile() (turnstileScript.ts) clears the used token after a
// rejected submit.
import { useEffect, useRef } from "react";
-import { loadTurnstileScript } from "./turnstile";
+import { loadTurnstileScript } from "./turnstileScript";
export function Turnstile({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
const el = useRef(null);
diff --git a/forms/src/observability.ts b/forms/src/observability.ts
index 505c38a77..91a510d70 100644
--- a/forms/src/observability.ts
+++ b/forms/src/observability.ts
@@ -16,7 +16,7 @@
// surface where that is the right call: the screen would be somebody typing
// their answers into a customer's form. Pageviews, autocapture, heatmaps, web
// vitals, exceptions and the named funnel events below all work without it.
-import type { PostHog } from "posthog-js";
+import type { CaptureResult, PostHog } from "posthog-js";
let client: PostHog | null = null;
@@ -60,6 +60,7 @@ export function initErrorReporting(): void {
capture_console_errors: true,
}
: false,
+ before_send: dropBrowserNoise,
});
// Named so form-page events are separable from the dashboard's in a
// shared project, the same way the Go services set a service
@@ -114,3 +115,22 @@ export function track(event: Event, form: string): void {
}
if (pending && pending.length < PENDING_LIMIT) pending.push({ event, form });
}
+
+// Browser noise: reported by the window error handler with no stack and no bug
+// behind it. A form page is embedded in a customer's own site, so a script of
+// theirs failing arrives here as the opaque "Script error."; the ResizeObserver
+// notice is a benign scheduling message the spec requires browsers to fire.
+// The dashboard, the admin panel and the marketing site drop the same three.
+const NOISE = [
+ "Script error.",
+ "ResizeObserver loop completed with undelivered notifications.",
+ "ResizeObserver loop limit exceeded",
+];
+
+function dropBrowserNoise(event: CaptureResult | null): CaptureResult | null {
+ if (!event?.properties || event.event !== "$exception") return event;
+ const values = event.properties.$exception_values;
+ if (!Array.isArray(values)) return event;
+ if (values.some((v) => typeof v === "string" && NOISE.includes(v.trim()))) return null;
+ return event;
+}
diff --git a/forms/src/turnstileScript.ts b/forms/src/turnstileScript.ts
new file mode 100644
index 000000000..b31e17f85
--- /dev/null
+++ b/forms/src/turnstileScript.ts
@@ -0,0 +1,47 @@
+// Cloudflare Turnstile script plumbing, split from the widget component so
+// the component file only exports components (react-refresh constraint).
+
+export interface TurnstileAPI {
+ render: (el: HTMLElement, opts: Record) => string;
+ remove: (id: string) => void;
+ reset: (id?: string) => void;
+}
+
+declare global {
+ interface Window {
+ turnstile?: TurnstileAPI;
+ }
+}
+
+const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
+
+let scriptLoading: Promise | null = null;
+
+export function loadTurnstileScript(): Promise {
+ if (window.turnstile) return Promise.resolve();
+ scriptLoading ??= new Promise((resolve, reject) => {
+ const s = document.createElement("script");
+ s.src = SCRIPT_SRC;
+ s.async = true;
+ s.onload = () => resolve();
+ s.onerror = () => {
+ // Drop the cached promise and the dead tag, or every later mount
+ // gets this same rejection back: one blocked or flaky load left the
+ // captcha permanently missing, and a form that requires one cannot
+ // be submitted at all until the visitor reloads the page.
+ scriptLoading = null;
+ s.remove();
+ reject(new Error("turnstile script failed to load"));
+ };
+ document.head.appendChild(s);
+ });
+ return scriptLoading;
+}
+
+export function resetTurnstile() {
+ try {
+ window.turnstile?.reset();
+ } catch {
+ // a widget that was never rendered has nothing to reset
+ }
+}
diff --git a/internal/infrastructure/eventbus/kafka_topics.go b/internal/infrastructure/eventbus/kafka_topics.go
index d2ac61b89..c026d1293 100644
--- a/internal/infrastructure/eventbus/kafka_topics.go
+++ b/internal/infrastructure/eventbus/kafka_topics.go
@@ -72,6 +72,19 @@ func (b *KafkaBus) ensureTopics(ctx context.Context, names ...string) error {
case ckf.ErrTopicAlreadyExists:
// The steady state on every process after the first.
default:
+ // An authorization failure is not a missing topic. A managed
+ // cluster hands out a key with Write and Read and creates topics
+ // from its own console, so it answers this for a topic that is
+ // already there. Failing on it dropped every event and filed one
+ // issue per message forever, because the topic never became known.
+ // Remember it instead and let the produce decide: a topic that
+ // really is absent fails there, saying exactly that.
+ if isAuthorizationFailure(r.Error) {
+ log.Warn().
+ Str("topic", r.Topic).
+ Msg("eventbus kafka: not allowed to create topics on this cluster; assuming it owns them")
+ break
+ }
// A replication factor the cluster cannot satisfy is the usual
// cause on a single-broker development cluster, and the bare
// error does not say so.
@@ -92,6 +105,25 @@ func (b *KafkaBus) ensureTopics(ctx context.Context, names ...string) error {
return nil
}
+// isAuthorizationFailure reports whether the broker refused the create because
+// this key may not make topics, rather than because the create itself was bad.
+//
+// The two codes are the answer; the description is a fallback because Confluent
+// Cloud substitutes its own "Authorization failed." for them and the code that
+// arrives with it is not documented. The fallback is the whole description and
+// not a substring on purpose: this waves a topic through as created, so an error
+// that merely mentions authorization must not be mistaken for a refusal to
+// create one, or the topic is remembered as present and every later publish to
+// it fails for a reason nothing reported.
+func isAuthorizationFailure(err ckf.Error) bool {
+ switch err.Code() {
+ case ckf.ErrTopicAuthorizationFailed, ckf.ErrClusterAuthorizationFailed:
+ return true
+ }
+ desc := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(err.String())), ".")
+ return desc == "authorization failed"
+}
+
// unknownTopics returns specs for the names this process has not created yet.
// Short critical section: no network happens under the lock.
func (b *KafkaBus) unknownTopics(names []string) ([]ckf.TopicSpecification, error) {
diff --git a/internal/infrastructure/eventbus/kafka_topics_test.go b/internal/infrastructure/eventbus/kafka_topics_test.go
index 0d443a4d8..e07df1888 100644
--- a/internal/infrastructure/eventbus/kafka_topics_test.go
+++ b/internal/infrastructure/eventbus/kafka_topics_test.go
@@ -6,6 +6,8 @@ import (
"context"
"testing"
"time"
+
+ ckf "github.com/confluentinc/confluent-kafka-go/v2/kafka"
)
// A topic already created by this process must not reach the broker again:
@@ -92,3 +94,39 @@ func TestUnknownTopicsDoesNotHoldLockForCaller(t *testing.T) {
t.Fatal("the topic lock was still held after unknownTopics returned")
}
}
+
+// A managed cluster refuses the create for a topic it already owns, and that
+// answer used to fail the publish and requeue the create for the next message:
+// one lost event and one reported issue per message, forever. The refusal must
+// read as "not ours to create" whichever of the two codes carries it, and
+// whatever description the broker substituted for them.
+func TestAuthorizationFailureIsNotACreateFailure(t *testing.T) {
+ refusals := []ckf.Error{
+ ckf.NewError(ckf.ErrTopicAuthorizationFailed, "Broker: Topic authorization failed", false),
+ ckf.NewError(ckf.ErrClusterAuthorizationFailed, "Broker: Cluster authorization failed", false),
+ // What Confluent Cloud actually sends, under a code of its choosing.
+ ckf.NewError(ckf.ErrUnknown, "Authorization failed.", false),
+ }
+ for _, r := range refusals {
+ if !isAuthorizationFailure(r) {
+ t.Errorf("a create refused for permissions read as a create failure: %v", r)
+ }
+ }
+
+ // The single-broker development case must still fail loudly: nothing is
+ // going to produce to a topic the cluster could not build. Neither must an
+ // error that only mentions authorization, because waving one through
+ // remembers a topic that was never created and every later publish to it
+ // then fails with nothing reporting why.
+ notRefusals := []ckf.Error{
+ ckf.NewError(ckf.ErrInvalidReplicationFactor, "Broker: Invalid replication factor", false),
+ ckf.NewError(ckf.ErrTopicException, "Broker: Invalid topic", false),
+ ckf.NewError(ckf.ErrUnknown, "Create failed after transactional id authorization failed for this client", false),
+ ckf.NewError(ckf.ErrUnknown, "Broker: SASL authentication failed", false),
+ }
+ for _, r := range notRefusals {
+ if isAuthorizationFailure(r) {
+ t.Errorf("a real create failure was waved through as a permissions refusal: %v", r)
+ }
+ }
+}
diff --git a/internal/observability/errs/errs.go b/internal/observability/errs/errs.go
index f8f6bb12a..97af6b383 100644
--- a/internal/observability/errs/errs.go
+++ b/internal/observability/errs/errs.go
@@ -19,6 +19,7 @@ package errs
import (
"context"
+ "errors"
"log"
"sync/atomic"
"time"
@@ -136,7 +137,7 @@ type event struct {
// CaptureException reports err. A nil error is dropped: a caller that reports
// unconditionally should not mint an issue with nothing in it.
func CaptureException(err error, opts ...Option) {
- if err == nil {
+ if err == nil || abandoned(err) {
return
}
report(event{err: err, scope: build(opts)})
@@ -145,12 +146,27 @@ func CaptureException(err error, opts ...Option) {
// CaptureExceptionContext reports err on the reporting state carried by ctx
// when there is one, so a request's scope travels with the event.
func CaptureExceptionContext(ctx context.Context, err error, opts ...Option) {
- if err == nil {
+ if err == nil || abandoned(err) {
return
}
report(event{ctx: ctx, err: err, scope: build(opts)})
}
+// abandoned reports whether err says the caller stopped waiting, rather than
+// that anything went wrong. A cancelled context is a browser navigating away, a
+// client hanging up or a container draining on deploy: the work was dropped on
+// purpose, and every layer it unwound through reported the same non-event, so a
+// rolling restart filed a handful of issues naming whichever queries happened to
+// be in flight.
+//
+// Dropped here rather than at each call site because the caller cannot tell:
+// a repository reporting a failed query has no idea whether the request behind
+// it still exists. context.DeadlineExceeded is deliberately not included; a
+// deadline this process set and then blew through is its own problem.
+func abandoned(err error) bool {
+ return errors.Is(err, context.Canceled)
+}
+
// CaptureMessage reports a message with no error attached.
func CaptureMessage(message string, opts ...Option) {
if message == "" {
diff --git a/internal/observability/errs/errs_test.go b/internal/observability/errs/errs_test.go
index f6e7862fd..fdaed4ef6 100644
--- a/internal/observability/errs/errs_test.go
+++ b/internal/observability/errs/errs_test.go
@@ -2,7 +2,9 @@ package errs
import (
"bytes"
+ "context"
"errors"
+ "fmt"
"io"
"log"
"net/http"
@@ -99,3 +101,35 @@ func TestPostHogPostsAnException(t *testing.T) {
}
}
}
+
+// A rolling restart cancels every request in flight, and each one unwound
+// through a repository that reported the failed query. Nine issues naming
+// whichever statements happened to be running is not a deploy anybody needs
+// told about, so a cancelled context must not reach a backend at all. A
+// deadline this process set still must.
+func TestCancelledWorkIsNotReported(t *testing.T) {
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
+ if err := Init(Config{Service: "backend", Environment: "dev"}); err != nil {
+ t.Fatalf("Init: %v", err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ CaptureException(context.Canceled)
+ // Wrapped the way a repository reports it, through the query text.
+ CaptureException(fmt.Errorf("queryrow failed: %w", context.Canceled))
+ CaptureExceptionContext(ctx, fmt.Errorf("get organization: %w", context.Canceled))
+
+ if strings.Contains(buf.String(), "[issue-local]") {
+ t.Errorf("a cancelled request was reported: %s", buf.String())
+ }
+
+ CaptureException(fmt.Errorf("queryrow failed: %w", context.DeadlineExceeded))
+ if !strings.Contains(buf.String(), "[issue-local]") {
+ t.Error("a deadline this process set and blew through was dropped as if the caller had gone away")
+ }
+}
diff --git a/internal/repository/pg_admin.go b/internal/repository/pg_admin.go
index 297c6a1f5..c2357e11c 100644
--- a/internal/repository/pg_admin.go
+++ b/internal/repository/pg_admin.go
@@ -256,7 +256,10 @@ func (r *adminRepository) SearchUsers(ctx context.Context, search *models.AdminU
}
defer rows.Close()
- var users []models.AdminUserDetail
+ // Every list this file builds is made rather than declared: a nil slice
+ // marshals to JSON null, and the panel reads .length off these without a
+ // guard, so an empty result took the page down instead of showing "none".
+ users := make([]models.AdminUserDetail, 0)
for rows.Next() {
var u models.AdminUserDetail
err := rows.Scan(
@@ -437,9 +440,7 @@ func (r *adminRepository) GetUserPreview(ctx context.Context, userID uuid.UUID)
if len(bans) > 5 {
bans = bans[:5]
}
- if bans != nil {
- preview.RecentBans = bans
- }
+ preview.RecentBans = bans
// Get rate limits
limits, err := r.GetUserRateLimits(ctx, userID)
@@ -566,7 +567,7 @@ func (r *adminRepository) GetUserBans(ctx context.Context, userID uuid.UUID) ([]
}
defer rows.Close()
- var bans []models.UserBan
+ bans := make([]models.UserBan, 0)
for rows.Next() {
var ban models.UserBan
var bannedByUser models.AdminUserSummary
@@ -628,7 +629,7 @@ func (r *adminRepository) GetUserEmails(ctx context.Context, userID uuid.UUID, c
}
defer rows.Close()
- var emails []models.AdminWorkerEmail
+ emails := make([]models.AdminWorkerEmail, 0)
for rows.Next() {
var e models.AdminWorkerEmail
err := rows.Scan(
@@ -683,7 +684,7 @@ func (r *adminRepository) ListAdmins(ctx context.Context, cursor *uuid.UUID, lim
}
defer rows.Close()
- var admins []models.AdminInfo
+ admins := make([]models.AdminInfo, 0)
for rows.Next() {
var admin models.AdminInfo
var grantedByID *uuid.UUID
@@ -757,7 +758,7 @@ func (r *adminRepository) ListWorkers(ctx context.Context, cursor *uuid.UUID, li
}
defer rows.Close()
- var workers []models.AdminWorkerDetail
+ workers := make([]models.AdminWorkerDetail, 0)
for rows.Next() {
var w models.AdminWorkerDetail
err := rows.Scan(
@@ -905,7 +906,7 @@ func (r *adminRepository) GetWorkerEmails(ctx context.Context, workerID uuid.UUI
}
defer rows.Close()
- var emails []models.AdminWorkerEmail
+ emails := make([]models.AdminWorkerEmail, 0)
for rows.Next() {
var e models.AdminWorkerEmail
err := rows.Scan(
@@ -1004,7 +1005,7 @@ func (r *adminRepository) ListWarmupPools(ctx context.Context) ([]models.WarmupP
}
defer rows.Close()
- var pools []models.WarmupPoolInfo
+ pools := make([]models.WarmupPoolInfo, 0)
for rows.Next() {
var p models.WarmupPoolInfo
if err := rows.Scan(&p.Type, &p.TotalParticipants, &p.ActiveParticipants, &p.BlockedCount); err != nil {
@@ -1050,7 +1051,7 @@ func (r *adminRepository) GetPoolParticipants(ctx context.Context, poolType stri
}
defer rows.Close()
- var participants []models.WarmupPoolParticipant
+ participants := make([]models.WarmupPoolParticipant, 0)
for rows.Next() {
var p models.WarmupPoolParticipant
if err := rows.Scan(
@@ -1113,7 +1114,7 @@ func (r *adminRepository) ListBlockedAccounts(ctx context.Context, cursor *uuid.
}
defer rows.Close()
- var accounts []models.AdminBlockedAccount
+ accounts := make([]models.AdminBlockedAccount, 0)
for rows.Next() {
var a models.AdminBlockedAccount
var user models.AdminUserSummary
@@ -1238,7 +1239,7 @@ func (r *adminRepository) ListAppeals(ctx context.Context, status string, cursor
}
defer rows.Close()
- var appeals []models.WarmupAppeal
+ appeals := make([]models.WarmupAppeal, 0)
for rows.Next() {
var a models.WarmupAppeal
var user models.AdminUserSummary
@@ -1511,7 +1512,7 @@ func (r *adminRepository) SearchCampaigns(ctx context.Context, search *models.Ad
}
defer rows.Close()
- var campaigns []models.AdminCampaignDetail
+ campaigns := make([]models.AdminCampaignDetail, 0)
for rows.Next() {
var c models.AdminCampaignDetail
var user models.AdminUserSummary
@@ -1697,7 +1698,7 @@ func (r *adminRepository) SearchAuditLogs(ctx context.Context, search *models.Ad
}
defer rows.Close()
- var logs []models.AdminAuditLog
+ logs := make([]models.AdminAuditLog, 0)
for rows.Next() {
var log models.AdminAuditLog
var user models.AdminUserSummary
@@ -1806,7 +1807,7 @@ func (r *adminRepository) GetUserGrowthStats(ctx context.Context, startDate, end
}
defer rows.Close()
- var stats []models.UserGrowthStats
+ stats := make([]models.UserGrowthStats, 0)
for rows.Next() {
var s models.UserGrowthStats
err := rows.Scan(&s.Date, &s.NewUsers)
diff --git a/site/src/layouts/Layout.astro b/site/src/layouts/Layout.astro
index 4c80d1273..2faba1a28 100644
--- a/site/src/layouts/Layout.astro
+++ b/site/src/layouts/Layout.astro
@@ -191,6 +191,27 @@ const websiteJsonLd = {
capture_exceptions: true,
disable_session_recording: true,
respect_dnt: false,
+ // Browser noise the window error handler reports with no stack and
+ // no bug behind it: "Script error." is what a cross-origin script is
+ // flattened to, and the ResizeObserver notice is a benign scheduling
+ // message the spec requires browsers to fire. Between them they were
+ // the two largest issues in error tracking, all of it from this page,
+ // which is what buried the real ones. The dashboard and the admin
+ // panel already drop the same three.
+ before_send: function (event) {
+ if (!event || event.event !== '$exception') return event;
+ var values = event.properties && event.properties.$exception_values;
+ if (!Array.isArray(values)) return event;
+ var noise = [
+ 'Script error.',
+ 'ResizeObserver loop completed with undelivered notifications.',
+ 'ResizeObserver loop limit exceeded',
+ ];
+ for (var i = 0; i < values.length; i++) {
+ if (typeof values[i] === 'string' && noise.indexOf(values[i].trim()) !== -1) return null;
+ }
+ return event;
+ },
});
window.posthog.register({ service: 'site' });
diff --git a/web/package.json b/web/package.json
index 5e1223785..abfe73123 100644
--- a/web/package.json
+++ b/web/package.json
@@ -13,8 +13,9 @@
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
- "build:pages": "vite build && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
- "sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after"
+ "build:pages": "vite build && pnpm sourcemaps:maybe && WARMBLY_CONFIG_OUT=dist/config.js sh ./docker-entrypoint.sh",
+ "sourcemaps:posthog": "pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap inject --directory dist && pnpm dlx --package @posthog/cli@0.18.2 posthog-cli sourcemap upload --directory dist --delete-after",
+ "sourcemaps:maybe": "sh -c 'if [ -n \"$POSTHOG_CLI_PROJECT_ID\" ] && [ -n \"$POSTHOG_CLI_API_KEY\" ]; then pnpm sourcemaps:posthog; else echo \"no PostHog CLI credentials; skipping the source map upload\"; fi'"
},
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
diff --git a/web/src/app/app/settings/sending/page.tsx b/web/src/app/app/settings/sending/page.tsx
index e8cba496b..f6bf222fc 100644
--- a/web/src/app/app/settings/sending/page.tsx
+++ b/web/src/app/app/settings/sending/page.tsx
@@ -4,6 +4,7 @@
// what the current selection means before anyone saves it.
import React from "react";
+import { useAppStore } from "@/stores";
import { ClockIcon } from "lucide-react";
import { Row, Section, SectionShell, Toggle } from "../_components/SectionShell";
import { NoAccess } from "@/components/layout/NoAccess";
@@ -56,25 +57,34 @@ function SendingSettings() {
const timezones = useTimezones();
const [draft, setDraft] = React.useState(null);
+ // These are one workspace's settings, so the draft belongs to the workspace
+ // it was hydrated from. Switching workspaces re-hydrates it, and a save that
+ // would land on a different workspace than the draft came from is dropped:
+ // otherwise the next edit after a switch wrote the previous workspace's
+ // whole settings object onto the new one.
+ const orgID = useAppStore((st) => st.currentOrganization?.id);
+ const hydratedFor = React.useRef(undefined);
+
const autosave = useAutosave({
value: draft,
enabled: !!draft,
save: async (v) => {
- if (v) await update.mutateAsync(v);
+ if (!v) return;
+ if (hydratedFor.current !== useAppStore.getState().currentOrganization?.id) return;
+ await update.mutateAsync(v);
},
});
useRegisterUnsaved(autosave, () => setDraft(autosave.savedValue));
- // One-shot hydration: the server value seeds the draft once, then the save
- // path owns the baseline so a refetch can't stomp an in-flight edit.
- const hydrated = React.useRef(false);
+ // Hydration is once per workspace: the server value seeds the draft, then
+ // the save path owns the baseline so a refetch can't stomp an in-flight edit.
React.useEffect(() => {
- if (!data || hydrated.current) return;
- hydrated.current = true;
+ if (!data || hydratedFor.current === orgID) return;
+ hydratedFor.current = orgID;
setDraft(data);
autosave.markSaved(data);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [data]);
+ }, [data, orgID]);
const sto = draft?.send_time_optimization;
diff --git a/web/src/app/app/settings/tracking/page.tsx b/web/src/app/app/settings/tracking/page.tsx
index 1c9229b97..d061ef8f3 100644
--- a/web/src/app/app/settings/tracking/page.tsx
+++ b/web/src/app/app/settings/tracking/page.tsx
@@ -4,6 +4,7 @@
// workspace's decision, enforced on the server rather than in the snippet.
import React from "react";
+import { useAppStore } from "@/stores";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Row, Section, SectionShell, Toggle } from "../_components/SectionShell";
import { NoAccess } from "@/components/layout/NoAccess";
@@ -80,27 +81,34 @@ function WebsiteTrackingSettingsView() {
const confirm = useConfirm();
const [draft, setDraft] = React.useState(null);
+ // One workspace's tracking settings, so the draft belongs to the workspace
+ // it was hydrated from: switching re-hydrates it, and a save that would land
+ // on a different workspace is dropped rather than written to it.
+ const orgID = useAppStore((st) => st.currentOrganization?.id);
+ const hydratedFor = React.useRef(undefined);
+
const autosave = useAutosave({
value: draft,
enabled: !!draft,
debounceMs: 600,
save: async (v) => {
- if (v) await update.mutateAsync(toPatch(v));
+ if (!v) return;
+ if (hydratedFor.current !== useAppStore.getState().currentOrganization?.id) return;
+ await update.mutateAsync(toPatch(v));
},
});
useRegisterUnsaved(autosave, () => setDraft(autosave.savedValue));
- // One-shot hydration, as on the other autosave settings pages: the server
- // seeds the draft once and the save path owns the baseline after that.
- const hydrated = React.useRef(false);
+ // Hydration is once per workspace, as on the other autosave settings pages:
+ // the server seeds the draft and the save path owns the baseline after that.
React.useEffect(() => {
- if (!data || hydrated.current) return;
- hydrated.current = true;
+ if (!data || hydratedFor.current === orgID) return;
+ hydratedFor.current = orgID;
const d = toDraft(data);
setDraft(d);
autosave.markSaved(d);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [data]);
+ }, [data, orgID]);
const patch = React.useCallback((next: Partial) => {
setDraft((prev) => (prev ? { ...prev, ...next } : prev));
diff --git a/web/src/app/app/settings/workspace/page.tsx b/web/src/app/app/settings/workspace/page.tsx
index 04018665a..9bbe689b5 100644
--- a/web/src/app/app/settings/workspace/page.tsx
+++ b/web/src/app/app/settings/workspace/page.tsx
@@ -1,9 +1,10 @@
import React from "react";
import { Link } from "react-router-dom";
-import { useAppStore } from "@/stores";
+import { useAppStore, type Organization as StoreOrganization } from "@/stores";
import { TextInput } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import useUpdateOrganization from "@/lib/api/hooks/app/organizations/useUpdateOrganization";
+import type Organization from "@/lib/api/models/app/organizations/Organization";
import { AvatarUploader } from "@/components/app/avatar/AvatarUploader";
import {
useDeleteOrgAvatar,
@@ -18,14 +19,38 @@ import { usePermission } from "@/hooks/usePermission";
import useAiMetered from "@/hooks/useAiMetered";
import AdvisorSettingsSection from "@/components/app/advisor/AdvisorSettingsSection";
+// Keyed on the workspace id, which is what makes a switch re-seed the editors
+// below. Each of them takes its initial value from the org it mounted with, and
+// nothing here re-reads that on a change: the name field kept the previous
+// workspace's name while the autosave baseline moved to the new one, so merely
+// switching workspaces (or creating one, which switches to it) saved the old
+// name over the new workspace's. That is the reported bug where renaming one
+// workspace renamed the other.
export default function WorkspaceSettingsPage() {
const currentOrg = useAppStore((s) => s.currentOrganization);
+ return ;
+}
+
+function WorkspaceSettings({ org: currentOrg }: { org: StoreOrganization | null }) {
const [name, setName] = React.useState(currentOrg?.name ?? "");
+ const orgID = currentOrg?.id;
const uploadOrgAvatar = useUploadOrgAvatar();
const removeOrgAvatar = useDeleteOrgAvatar();
const updateOrg = useUpdateOrganization();
+ // Every save here renames whatever workspace the server session has
+ // selected, and a debounce or a blur armed on this page can land after a
+ // switch. orgID is the workspace this editor was opened for, so a write
+ // that would reach a different one is dropped rather than applied to it.
+ const saveToThisWorkspace = React.useCallback(
+ async (patch: Partial) => {
+ if (!orgID || useAppStore.getState().currentOrganization?.id !== orgID) return;
+ await updateOrg.mutateAsync(patch);
+ },
+ [orgID, updateOrg],
+ );
+
// Team presence privacy. The full org (with the flags) comes from
// /organization/current; toggling saves immediately and the realtime
// service re-gates everyone live. Only admins with Manage settings can edit.
@@ -43,11 +68,11 @@ export default function WorkspaceSettingsPage() {
const onToggleOnline = (next: boolean) => {
setShowOnline(next);
- updateOrg.mutate({ presence_show_online: next });
+ void saveToThisWorkspace({ presence_show_online: next });
};
const onToggleActivity = (next: boolean) => {
setShowActivity(next);
- updateOrg.mutate({ presence_show_activity: next });
+ void saveToThisWorkspace({ presence_show_activity: next });
};
// AI voice profile. Grounds every AI writing surface. Saved on blur when
@@ -67,7 +92,7 @@ export default function WorkspaceSettingsPage() {
orgQuery.data?.voice_profile,
]);
const saveVoiceField = (key: "product_description" | "icp_notes" | "voice_profile", value: string, saved: string) => {
- if (value !== saved) updateOrg.mutate({ [key]: value });
+ if (value !== saved) void saveToThisWorkspace({ [key]: value });
};
// Inbox agent opt-in (paid). When on, an inbound human reply gets an
@@ -81,11 +106,11 @@ export default function WorkspaceSettingsPage() {
}, [orgQuery.data?.inbox_agent_enabled, orgQuery.data?.assistant_shared_history]);
const onToggleInboxAgent = (next: boolean) => {
setInboxAgent(next);
- updateOrg.mutate({ inbox_agent_enabled: next });
+ void saveToThisWorkspace({ inbox_agent_enabled: next });
};
const onToggleSharedHistory = (next: boolean) => {
setSharedHistory(next);
- updateOrg.mutate({ assistant_shared_history: next });
+ void saveToThisWorkspace({ assistant_shared_history: next });
};
// Auto-save the workspace name ~700ms after typing stops. An empty name is
@@ -95,7 +120,7 @@ export default function WorkspaceSettingsPage() {
debounceMs: 700,
save: async (v) => {
if (!v) throw new Error("name required");
- await updateOrg.mutateAsync({ name: v });
+ await saveToThisWorkspace({ name: v });
},
});
useRegisterUnsaved(autosave, () => setName(autosave.savedValue));
|