diff --git a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx index 0efbfa33ac8..b79a51dc1f2 100644 --- a/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/Navigation/LayoutHeader/LayoutHeader.tsx @@ -1,6 +1,5 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common' import dayjs from 'dayjs' -import { DevToolbarTrigger } from 'dev-tools' import { AnimatePresence, motion } from 'framer-motion' import { ChevronLeft } from 'lucide-react' import Link from 'next/link' @@ -224,7 +223,6 @@ export const LayoutHeader = ({ {customHeaderComponents && customHeaderComponents} {IS_PLATFORM ? ( <> -
+ {JSON.stringify(event.properties, null, 2)} )} @@ -94,7 +135,7 @@ function EventCard({ event }: { event: DevTelemetryEvent }) { ) } -function FlagCard({ +function FlagRow({ flagName, currentValue, originalValue, @@ -110,30 +151,44 @@ function FlagCard({ const valueType = typeof originalValue const isNull = originalValue === null const inputProps = { + size: 'tiny' as const, value: String(currentValue), onChange: (event: ChangeEvent) => onToggle(event.target.value), className: 'w-32', } return ( - - - - {flagName} - {isOverridden && ( - - Overridden - - )} - {isNull && ( - - null - - )} + + + + + + {flagName} + + {isOverridden && ( + + Overridden + + )} + {isNull && ( + + null + + )} + + + Original:{' '} + {JSON.stringify(originalValue)} + {isNull ? ( - + ) : valueType === 'boolean' ? ( )} - - {isOverridden && ( - - Original:{' '} - {JSON.stringify(originalValue)} - - )} ) } export function DevToolbar() { - const { isEnabled, isOpen, setIsOpen, events, setEvents } = useDevToolbar() + const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar() const [activeTab, setActiveTab] = useState('events') const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog') const [eventFilter, setEventFilter] = useState('') @@ -218,6 +266,24 @@ export function DevToolbar() { setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY)) }, []) + useEffect(() => { + const STYLE_ID = 'dev-toolbar-hide-native-devtools' + const existing = document.getElementById(STYLE_ID) + + if (isOpen) { + if (!existing) { + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = ` + .tsqd-open-btn, .tsqd-open-btn-container { display: none !important; } + ` + document.head.appendChild(style) + } + } else { + existing?.remove() + } + }, [isOpen]) + const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals) const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals) @@ -284,7 +350,7 @@ export function DevToolbar() { const ccOverrideCount = Object.keys(ccFlagOverrides).length const totalOverrideCount = phOverrideCount + ccOverrideCount - if (!IS_LOCAL_DEV || !isEnabled) return null + if (!IS_TOOLBAR_ENABLED || !isEnabled) return null return ( @@ -292,146 +358,203 @@ export function DevToolbar() { side="bottom" size="lg" className="flex flex-col p-0 gap-0 overflow-hidden" + showClose={false} + hasOverlay={false} onOpenAutoFocus={(event) => event.preventDefault()} > - - - - Dev Telemetry - Local Only - - - View telemetry events and feature flags for local development - - + + + Dev Toolbar + + View telemetry events and feature flags for local development + + + + + + Events ({filteredEvents.length}) + + + Flags {totalOverrideCount > 0 && `(${totalOverrideCount})`} + + + + + + } + onClick={dismissToolbar} + className="text-foreground-light hover:text-foreground p-1" + /> + + Hide Dev Toolbar + + + + + } + className="text-foreground-light hover:text-foreground p-1" + /> + + + Close + + + + - - - - - - Events ({filteredEvents.length}) - - - - Flags {totalOverrideCount > 0 && `(${totalOverrideCount} overrides)`} - - + {activeTab === 'events' && ( - - + + setEventFilter(e.target.value)} - className="flex-1" + icon={} + className="flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 max-w-96" /> - setEvents([])}> - Clear + setEvents([])} + className="text-foreground-lighter hover:text-foreground" + > + Clear all - + {!IS_LOCAL_DEV && ( + + Server-side events are only visible when using the toolbar in local development + + )} + + {filteredEvents.length === 0 ? ( - + No events yet. Interact with the app to see telemetry events. ) : ( - filteredEvents.map((event) => ( - - )) + + {filteredEvents.map((event) => ( + + ))} + )} )} {activeTab === 'flags' && ( - - {totalOverrideCount > 0 && ( - - - {totalOverrideCount} flag(s) overridden - {phOverrideCount > 0 && ccOverrideCount > 0 - ? ` (${phOverrideCount} PostHog, ${ccOverrideCount} ConfigCat)` - : ''} - - - Clear & Reload - + + {/* Sidebar */} + + + {( + [ + { id: 'posthog', label: 'PostHog', count: phOverrideCount }, + { id: 'configcat', label: 'ConfigCat', count: ccOverrideCount }, + ] as const + ).map(({ id, label, count }) => ( + setFlagsSubTab(id)} + className={cn( + 'flex items-center justify-between px-3 py-1.5 rounded text-sm text-left uppercase font-mono tracking-wide', + flagsSubTab === id + ? 'bg-surface-300 text-foreground' + : 'text-foreground-light hover:bg-surface-200' + )} + > + {label} + {count > 0 && ( + {count} + )} + + ))} + + {totalOverrideCount > 0 && ( + + + Reset & Reload + + + )} + + + {/* Flag list */} + + + {flagsSubTab === 'posthog' && + (Object.keys(posthogFlags).length === 0 ? ( + + No PostHog feature flags loaded yet. + + ) : ( + Object.entries(posthogFlags).map(([flagName, flagValue]) => ( + togglePhFlagOverride(flagName, value)} + /> + )) + ))} + {flagsSubTab === 'configcat' && + (Object.keys(configcatFlags).length === 0 ? ( + + No ConfigCat feature flags loaded yet. + + ) : ( + Object.entries(configcatFlags).map(([flagName, flagValue]) => ( + toggleCcFlagOverride(flagName, value)} + /> + )) + ))} - )} - - setFlagsSubTab(v as 'posthog' | 'configcat')} - className="flex-1 flex flex-col min-h-0 overflow-hidden" - > - - - PostHog {phOverrideCount > 0 && `(${phOverrideCount})`} - - - ConfigCat {ccOverrideCount > 0 && `(${ccOverrideCount})`} - - - - {flagsSubTab === 'posthog' && ( - - - {Object.keys(posthogFlags).length === 0 ? ( - - No PostHog feature flags loaded yet. - - ) : ( - Object.entries(posthogFlags).map(([flagName, flagValue]) => ( - togglePhFlagOverride(flagName, value)} - /> - )) - )} - - - )} - {flagsSubTab === 'configcat' && ( - - - {Object.keys(configcatFlags).length === 0 ? ( - - No ConfigCat feature flags loaded yet. - - ) : ( - Object.entries(configcatFlags).map(([flagName, flagValue]) => ( - toggleCcFlagOverride(flagName, value)} - /> - )) - )} - - - )} - + )} - - + + ) diff --git a/packages/dev-tools/DevToolbarContext.tsx b/packages/dev-tools/DevToolbarContext.tsx index e1ef961fd97..0c46a1d0323 100644 --- a/packages/dev-tools/DevToolbarContext.tsx +++ b/packages/dev-tools/DevToolbarContext.tsx @@ -1,14 +1,14 @@ 'use client' -import { type ClientTelemetryEvent, ensurePlatformSuffix, posthogClient } from 'common' +import { ensurePlatformSuffix, posthogClient, type ClientTelemetryEvent } from 'common' import { - type ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState, + type ReactNode, } from 'react' import type { @@ -18,7 +18,11 @@ import type { } from './types' import { getCookie } from './utils' -const IS_LOCAL_DEV = process.env.NODE_ENV === 'development' +// Duplicated for tree-shaking — bundler must see literal process.env reference. +// Keep in sync: index.ts, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx +const env = process.env.NEXT_PUBLIC_ENVIRONMENT +const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging' +const IS_LOCAL_DEV = env === 'local' const MAX_EVENTS = 200 const STORAGE_KEY = 'dev-telemetry-toolbar-enabled' @@ -56,7 +60,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps }, []) useEffect(() => { - if (!IS_LOCAL_DEV) return + if (!IS_TOOLBAR_ENABLED) return let stored: string | null = null try { @@ -105,7 +109,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps }, [appendEvent, isEnabled]) useEffect(() => { - if (!isEnabled || typeof EventSource === 'undefined') return + if (!IS_LOCAL_DEV || !isEnabled || typeof EventSource === 'undefined') return let eventSource: EventSource | null = null let isMounted = true @@ -176,7 +180,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps } }, [apiUrl, appendEvent, isEnabled]) - if (!IS_LOCAL_DEV) { + if (!IS_TOOLBAR_ENABLED) { return <>{children}> } diff --git a/packages/dev-tools/DevToolbarTrigger.tsx b/packages/dev-tools/DevToolbarTrigger.tsx index 1a64eb43348..f6548c3c01c 100644 --- a/packages/dev-tools/DevToolbarTrigger.tsx +++ b/packages/dev-tools/DevToolbarTrigger.tsx @@ -1,113 +1,258 @@ 'use client' -import { Activity, EyeOff } from 'lucide-react' -import { useEffect, useRef, useState } from 'react' -import type { MouseEvent } from 'react' -import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui' +import Image from 'next/image' +import { useCallback, useEffect, useRef, useState } from 'react' +import type { CSSProperties, PointerEvent } from 'react' +import { Button, cn } from 'ui' import { useDevToolbar } from './DevToolbarContext' -const IS_LOCAL_DEV = process.env.NODE_ENV === 'development' +// Duplicated for tree-shaking — bundler must see literal process.env reference. +// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx +const env = process.env.NEXT_PUBLIC_ENVIRONMENT +const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging' +const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position' +const DRAG_THRESHOLD = 4 +const MARGIN = 24 +const BUTTON_SIZE = 40 // h-10 w-10 + +// Spring easing: slight overshoot then settle +const SNAP_TRANSITION = + 'top 380ms cubic-bezier(0.34, 1.56, 0.64, 1), left 380ms cubic-bezier(0.34, 1.56, 0.64, 1)' + +type SnapPosition = + | 'top-left' + | 'top-center' + | 'top-right' + | 'middle-left' + | 'middle-center' + | 'middle-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right' + +// All positions expressed as pixel top+left so transitions interpolate cleanly +function getSnapCoords( + position: SnapPosition, + vw: number, + vh: number +): { top: number; left: number } { + const [row, col] = position.split('-') + const top = + row === 'top' + ? MARGIN + : row === 'bottom' + ? vh - MARGIN - BUTTON_SIZE + : Math.round(vh / 2 - BUTTON_SIZE / 2) + const left = + col === 'left' + ? MARGIN + : col === 'right' + ? vw - MARGIN - BUTTON_SIZE + : Math.round(vw / 2 - BUTTON_SIZE / 2) + return { top, left } +} + +function getNearestSnapPosition(cx: number, cy: number): SnapPosition { + const vw = window.innerWidth + const vh = window.innerHeight + const row = cy < vh / 3 ? 'top' : cy > (2 * vh) / 3 ? 'bottom' : 'middle' + const col = cx < vw / 3 ? 'left' : cx > (2 * vw) / 3 ? 'right' : 'center' + return `${row}-${col}` as SnapPosition +} + +function readStoredPosition(): SnapPosition { + if (typeof window === 'undefined') return 'bottom-right' + return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right' +} export function DevToolbarTrigger() { - const { isEnabled, isOpen, setIsOpen, events, dismissToolbar } = useDevToolbar() - const [popoverOpen, setPopoverOpen] = useState(false) - const closeTimeoutRef = useRef(null) + const { isEnabled, isOpen, setIsOpen, events } = useDevToolbar() + const [snapPosition, setSnapPosition] = useState('bottom-right') + const [hasHydrated, setHasHydrated] = useState(false) + const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null) + // Holds the last drag pixel position for one RAF to prime the CSS transition + const [releasedAt, setReleasedAt] = useState<{ x: number; y: number } | null>(null) + const [viewport, setViewport] = useState(() => ({ + w: typeof window !== 'undefined' ? window.innerWidth : 1920, + h: typeof window !== 'undefined' ? window.innerHeight : 1080, + })) + const dragRef = useRef<{ + startPointerX: number + startPointerY: number + startButtonX: number + startButtonY: number + hasDragged: boolean + } | null>(null) + const wasDraggingRef = useRef(false) + + // Restore persisted position after mount to avoid SSR hydration mismatch. + // hasHydrated is set via RAF so the correct position is painted before transitions are enabled, + // preventing the spring animation from firing on initial load. useEffect(() => { - return () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - closeTimeoutRef.current = null - } - } + const stored = readStoredPosition() + if (stored !== 'bottom-right') setSnapPosition(stored) + const id = requestAnimationFrame(() => setHasHydrated(true)) + return () => cancelAnimationFrame(id) }, []) - if (!IS_LOCAL_DEV || !isEnabled) { - return null - } + // Keep snap coords accurate on resize + useEffect(() => { + const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight }) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + // Two-phase snap: hold last drag position for one frame (primes the transition), + // then clear it so the spring fires from that position to the snap target + useEffect(() => { + if (releasedAt === null) return + const id = requestAnimationFrame(() => setReleasedAt(null)) + return () => cancelAnimationFrame(id) + }, [releasedAt]) + + const handlePointerDown = useCallback((e: PointerEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + dragRef.current = { + startPointerX: e.clientX, + startPointerY: e.clientY, + startButtonX: rect.left, + startButtonY: rect.top, + hasDragged: false, + } + wasDraggingRef.current = false + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const handlePointerMove = useCallback((e: PointerEvent) => { + if (!dragRef.current) return + const dx = e.clientX - dragRef.current.startPointerX + const dy = e.clientY - dragRef.current.startPointerY + if (!dragRef.current.hasDragged && Math.hypot(dx, dy) < DRAG_THRESHOLD) return + dragRef.current.hasDragged = true + setDragPos({ + x: dragRef.current.startButtonX + dx, + y: dragRef.current.startButtonY + dy, + }) + }, []) + + const handlePointerUp = useCallback((e: PointerEvent) => { + if (!dragRef.current) return + const { hasDragged } = dragRef.current + dragRef.current = null + wasDraggingRef.current = hasDragged + if (!hasDragged) return + const rect = e.currentTarget.getBoundingClientRect() + const cx = rect.left + rect.width / 2 + const cy = rect.top + rect.height / 2 + const newPosition = getNearestSnapPosition(cx, cy) + setSnapPosition(newPosition) + localStorage.setItem(POSITION_STORAGE_KEY, newPosition) + // Phase 1: park at last drag position with transition primed + setReleasedAt({ x: rect.left, y: rect.top }) + setDragPos(null) + }, []) + + const handlePointerCancel = useCallback(() => { + dragRef.current = null + wasDraggingRef.current = false + setDragPos(null) + setReleasedAt(null) + }, []) + + if (!IS_TOOLBAR_ENABLED || !isEnabled) return null const eventCount = events.length + const isDragging = dragPos !== null + const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h) + const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease` + + const containerStyle: CSSProperties = + dragPos !== null + ? { + position: 'fixed', + zIndex: 50, + left: dragPos.x, + top: dragPos.y, + transition: 'none', + opacity: 1, + } + : releasedAt !== null + ? // Phase 1: same pixel position as drag end, transition now defined + { + position: 'fixed', + zIndex: 50, + left: releasedAt.x, + top: releasedAt.y, + transition: FULL_TRANSITION, + opacity: isOpen ? 0 : 1, + pointerEvents: isOpen ? 'none' : undefined, + } + : // Phase 2: spring fires from releasedAt → snapCoords + { + position: 'fixed', + zIndex: 50, + ...snapCoords, + transition: hasHydrated ? FULL_TRANSITION : 'none', + opacity: isOpen ? 0 : 1, + pointerEvents: isOpen ? 'none' : undefined, + } const handleClick = () => { - setPopoverOpen(false) + if (wasDraggingRef.current) { + wasDraggingRef.current = false + return + } setIsOpen(true) } - const handleDismiss = () => { - setPopoverOpen(false) - dismissToolbar() - } - - const handleMouseEnter = () => { - if (closeTimeoutRef.current) { - clearTimeout(closeTimeoutRef.current) - closeTimeoutRef.current = null - } - setPopoverOpen(true) - } - - const handleMouseLeave = () => { - closeTimeoutRef.current = setTimeout(() => { - setPopoverOpen(false) - }, 100) - } - - const handleTriggerMouseLeave = (event: MouseEvent) => { - handleMouseLeave() - event.currentTarget.blur() - } - return ( - - - - - {eventCount > 0 && ( - - {eventCount > 99 ? '99+' : eventCount} - - )} - - - event.preventDefault()} + + - - - Hide - - - + + {eventCount > 0 && ( + + {eventCount > 99 ? '99+' : eventCount} + + )} + + ) } diff --git a/packages/dev-tools/__mocks__/next-image.tsx b/packages/dev-tools/__mocks__/next-image.tsx new file mode 100644 index 00000000000..ec32634bea1 --- /dev/null +++ b/packages/dev-tools/__mocks__/next-image.tsx @@ -0,0 +1,8 @@ +import React from 'react' + +function MockImage(props: Record) { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return )} /> +} + +export default MockImage diff --git a/packages/dev-tools/index.ts b/packages/dev-tools/index.ts index e682df49fa3..ff32f4a13ff 100644 --- a/packages/dev-tools/index.ts +++ b/packages/dev-tools/index.ts @@ -5,10 +5,14 @@ import * as DevToolbarContextModule from './DevToolbarContext' import * as DevToolbarTriggerModule from './DevToolbarTrigger' import type { DevTelemetryToolbarContextType } from './types' -// Tree-shaking pattern: conditionally export stubs in production -// The bundler replaces process.env.NODE_ENV at build time, making the -// ternary static. Combined with sideEffects: false, the implementation -// modules are eliminated from the production bundle. +// Tree-shaking pattern: conditionally export stubs outside local/staging. +// The bundler replaces NEXT_PUBLIC_ENVIRONMENT at build time, making the +// ternary static. In production builds (env === 'prod'), the implementation +// modules are eliminated from the bundle. +// Duplicated for tree-shaking — bundler must see literal process.env reference. +// Keep in sync: DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx +const env = process.env.NEXT_PUBLIC_ENVIRONMENT +const isToolbarEnabled = env === 'local' || env === 'staging' const noopContext: DevTelemetryToolbarContextType = { isEnabled: false, @@ -19,18 +23,18 @@ const noopContext: DevTelemetryToolbarContextType = { dismissToolbar: () => {}, } -export const DevToolbarProvider = - process.env.NODE_ENV !== 'development' - ? ({ children }: { children: ReactNode; apiUrl?: string }) => children - : DevToolbarContextModule.DevToolbarProvider +export const DevToolbarProvider = !isToolbarEnabled + ? ({ children }: { children: ReactNode; apiUrl?: string }) => children + : DevToolbarContextModule.DevToolbarProvider -export const useDevToolbar = - process.env.NODE_ENV !== 'development' ? () => noopContext : DevToolbarContextModule.useDevToolbar +export const useDevToolbar = !isToolbarEnabled + ? () => noopContext + : DevToolbarContextModule.useDevToolbar -export const DevToolbar = - process.env.NODE_ENV !== 'development' ? () => null : DevToolbarModule.DevToolbar +export const DevToolbar = !isToolbarEnabled ? () => null : DevToolbarModule.DevToolbar -export const DevToolbarTrigger = - process.env.NODE_ENV !== 'development' ? () => null : DevToolbarTriggerModule.DevToolbarTrigger +export const DevToolbarTrigger = !isToolbarEnabled + ? () => null + : DevToolbarTriggerModule.DevToolbarTrigger export type { DevTelemetryEvent, DevToolbarConfig } from './types' diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index 1a8558d6212..fca2ebf7d8f 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -33,6 +33,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "next": "catalog:", "react": "catalog:", "react-dom": "catalog:" } diff --git a/packages/dev-tools/vitest.config.ts b/packages/dev-tools/vitest.config.ts index 489cb49b282..699e17e79ee 100644 --- a/packages/dev-tools/vitest.config.ts +++ b/packages/dev-tools/vitest.config.ts @@ -1,6 +1,12 @@ +import path from 'path' import { defineConfig } from 'vitest/config' export default defineConfig({ + resolve: { + alias: { + 'next/image': path.resolve(__dirname, './__mocks__/next-image.tsx'), + }, + }, test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], diff --git a/packages/dev-tools/vitest.setup.ts b/packages/dev-tools/vitest.setup.ts index 3d8e21849f6..cf425062246 100644 --- a/packages/dev-tools/vitest.setup.ts +++ b/packages/dev-tools/vitest.setup.ts @@ -1,6 +1,7 @@ /// import '@testing-library/jest-dom/vitest' + import { cleanup } from '@testing-library/react' import { afterEach, vi } from 'vitest' @@ -20,6 +21,10 @@ Object.defineProperty(window, 'matchMedia', { vi.mock('next/navigation', () => require('next-router-mock/navigation')) +// jsdom doesn't implement pointer capture methods +Element.prototype.setPointerCapture = vi.fn() +Element.prototype.releasePointerCapture = vi.fn() + afterEach(() => { cleanup() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24263a6d11d..999ad2c5d6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2253,6 +2253,9 @@ importers: lucide-react: specifier: '*' version: 0.511.0(react@18.3.1) + next: + specifier: 'catalog:' + version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: specifier: 'catalog:' version: 18.3.1
{JSON.stringify(event.properties, null, 2)}
{JSON.stringify(originalValue)}