+
+ );
+}
diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/index.ts b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/index.ts
new file mode 100644
index 000000000..377d2b4d7
--- /dev/null
+++ b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/index.ts
@@ -0,0 +1 @@
+export { LogsTableRow } from "./LogsTableRow";
diff --git a/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/shared.ts b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/shared.ts
new file mode 100644
index 000000000..0b0b2c3aa
--- /dev/null
+++ b/studio/src/pages/RequestorPage/LogsTable/LogsTableRow/shared.ts
@@ -0,0 +1,7 @@
+export function formatTimestamp(timestamp: Date) {
+ return timestamp.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
diff --git a/studio/src/pages/RequestorPage/LogsTable/data.ts b/studio/src/pages/RequestorPage/LogsTable/data.ts
new file mode 100644
index 000000000..20719599b
--- /dev/null
+++ b/studio/src/pages/RequestorPage/LogsTable/data.ts
@@ -0,0 +1,57 @@
+import type { MizuOrphanLog } from "@/queries";
+import {
+ getErrorEvents,
+ getNeonSqlQuery,
+ getResponseBody,
+ isJson,
+ isNeonFetch,
+ safeParseJson,
+} from "@/utils";
+import type { OtelSpan } from "@fiberplane/fpx-types";
+import { useMemo } from "react";
+import type { LogEntry, NeonEvent } from "./types";
+
+export function useLogsWithEvents(spans: OtelSpan[], logs: MizuOrphanLog[]) {
+ // Here we can insert relevant events that happend, in order to link back to the timeline.
+ // For now, we're just looking for Neon database queries
+ return useMemo(() => {
+ const neonSpans = spans?.filter((span) => isNeonFetch(span));
+ const neonEvents: NeonEvent[] = neonSpans?.map(neonSpanToEvent) ?? [];
+ if (neonEvents?.length) {
+ const result = [...logs, ...neonEvents];
+ return result.sort(
+ (a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
+ );
+ }
+
+ return logs;
+ }, [logs, spans]);
+}
+
+function neonSpanToEvent(span: OtelSpan): NeonEvent {
+ const responseBody = getResponseBody(span);
+ const parsedResponseBody =
+ responseBody && isJson(responseBody) ? safeParseJson(responseBody) : null;
+ const rowCount =
+ parsedResponseBody && "rowCount" in parsedResponseBody
+ ? Number.parseInt(parsedResponseBody.rowCount ?? "") ?? null
+ : null;
+
+ // E.g., "SELECT"
+ const command =
+ parsedResponseBody && "command" in parsedResponseBody
+ ? parsedResponseBody.command
+ : null;
+ const errorEvents = getErrorEvents(span);
+
+ return {
+ id: span.span_id,
+ type: "neon-event",
+ errors: errorEvents,
+ timestamp: span.end_time,
+ sql: getNeonSqlQuery(span),
+ command,
+ rowCount,
+ duration: span.end_time.getTime() - span.start_time.getTime(),
+ };
+}
diff --git a/studio/src/pages/RequestorPage/LogsTable/types.ts b/studio/src/pages/RequestorPage/LogsTable/types.ts
new file mode 100644
index 000000000..451f969a5
--- /dev/null
+++ b/studio/src/pages/RequestorPage/LogsTable/types.ts
@@ -0,0 +1,24 @@
+import type { MizuOrphanLog } from "@/queries";
+import { objectWithKey } from "@/utils";
+import type { OtelEvent } from "@fiberplane/fpx-types";
+
+export type NeonEvent = {
+ id: string;
+ type: "neon-event";
+ timestamp: Date;
+ sql: {
+ query: string;
+ params: Array;
+ };
+ duration: number;
+ rowCount: number | null;
+ /** E.g., "SELECT" (this is returned from the Neon API) */
+ command: string | null;
+ errors?: OtelEvent[];
+};
+
+export const isNeonEvent = (log: unknown): log is NeonEvent => {
+ return objectWithKey(log, "type") && log.type === "neon-event";
+};
+
+export type LogEntry = MizuOrphanLog | NeonEvent;
diff --git a/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx b/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx
index 32f63fd46..36ca3687c 100644
--- a/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx
+++ b/studio/src/pages/RequestorPage/RequestPanel/PathParamForm/PathParamForm.tsx
@@ -9,6 +9,8 @@ import { createChangePathParamValue } from "./data";
type Props = {
keyValueParameters: KeyValueParameter[];
onChange: ChangeKeyValueParametersHandler;
+ onSubmit?: () => void;
+ keyPlaceholder?: string;
};
/**
@@ -18,7 +20,7 @@ type Props = {
* Remember: Path params are *computed* from the route pattern.
*/
export const PathParamForm = (props: Props) => {
- const { onChange, keyValueParameters } = props;
+ const { onChange, keyValueParameters, onSubmit, keyPlaceholder } = props;
return (
@@ -39,6 +41,8 @@ export const PathParamForm = (props: Props) => {
keyValueParameters,
parameter,
)}
+ onSubmit={onSubmit}
+ keyPlaceholder={keyPlaceholder}
/>
);
})}
diff --git a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx
index 2f21c9dab..084086d3e 100644
--- a/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx
+++ b/studio/src/pages/RequestorPage/RequestPanel/RequestPanel.tsx
@@ -30,6 +30,7 @@ type RequestPanelProps = {
setIgnoreAiInputsBanner: Dispatch>;
websocketState: WebSocketState;
sendWebsocketMessage: (message: string) => void;
+ onSubmit: () => void;
};
export const RequestPanel = memo(function RequestPanel(
@@ -46,6 +47,7 @@ export const RequestPanel = memo(function RequestPanel(
setIgnoreAiInputsBanner,
websocketState,
sendWebsocketMessage,
+ onSubmit,
} = props;
const {
@@ -169,10 +171,12 @@ export const RequestPanel = memo(function RequestPanel(
}}
/>
{
setQueryParams(params);
}}
+ onSubmit={onSubmit}
/>
{pathParams.length > 0 ? (
<>
@@ -182,10 +186,12 @@ export const RequestPanel = memo(function RequestPanel(
className="mt-4"
/>
{
setPathParams(params);
}}
+ onSubmit={onSubmit}
/>
>
) : null}
@@ -203,10 +209,12 @@ export const RequestPanel = memo(function RequestPanel(
}}
/>
{
setRequestHeaders(headers);
}}
+ onSubmit={onSubmit}
/>
{shouldShowBody && (
@@ -234,6 +242,7 @@ export const RequestPanel = memo(function RequestPanel(
onChange={setBody}
value={body.value}
maxHeight="800px"
+ onSubmit={onSubmit}
/>
)}
{body.type === "form-data" && (
@@ -246,6 +255,7 @@ export const RequestPanel = memo(function RequestPanel(
value: params,
});
}}
+ onSubmit={onSubmit}
/>
)}
{body.type === "file" && (
diff --git a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx
index 80acff811..d3f9eabc2 100644
--- a/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx
+++ b/studio/src/pages/RequestorPage/RequestorPageContent/RequestorPageContent.tsx
@@ -182,6 +182,7 @@ export const RequestorPageContent: React.FC = (
setIgnoreAiInputsBanner={setIgnoreAiInputsBanner}
websocketState={websocketState}
sendWebsocketMessage={sendWebsocketMessage}
+ onSubmit={onSubmit}
/>
);
diff --git a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts
index 07d02d223..07d2ca528 100644
--- a/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts
+++ b/studio/src/pages/RequestorPage/store/slices/requestResponseSlice.ts
@@ -5,6 +5,7 @@ import { enforceTerminalDraftParameter } from "../../KeyValueForm";
import { findMatchedRoute } from "../../routes";
import { updateContentTypeHeaderInState } from "../content-type";
import { setBodyTypeInState } from "../set-body-type";
+import { getVisibleRequestPanelTabs } from "../tabs";
import {
addBaseUrl,
extractMatchedPathParams,
@@ -73,6 +74,19 @@ export const requestResponseSlice: StateCreator<
// Update other state properties based on the new method and request type
// (e.g., activeRoute, visibleRequestsPanelTabs, activeRequestsPanelTab, etc.)
// You might want to move some of this logic to separate functions or slices
+
+ // Update visibleRequestsPanelTabs based on the new method and request type
+ state.visibleRequestsPanelTabs = getVisibleRequestPanelTabs({
+ requestType,
+ method,
+ });
+
+ // Ensure the activeRequestsPanelTab is valid
+ state.activeRequestsPanelTab = state.visibleRequestsPanelTabs.includes(
+ state.activeRequestsPanelTab,
+ )
+ ? state.activeRequestsPanelTab
+ : state.visibleRequestsPanelTabs[0];
}),
setPathParams: (pathParams) =>
diff --git a/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts b/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts
index de1ecf58b..cb7639ae5 100644
--- a/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts
+++ b/studio/src/pages/RequestorPage/useRequestorSubmitHandler.ts
@@ -44,8 +44,9 @@ export function useRequestorSubmitHandler({
const { addServiceUrlIfBarePath } = useServiceBaseUrl();
const { activeHistoryResponseTraceId } = useRequestorStore();
- return useHandler((e: React.FormEvent) => {
- e.preventDefault();
+ // NOTE - We make the submit handler optional to make it easier to call this as a standalone function
+ return useHandler((e?: React.FormEvent) => {
+ e?.preventDefault?.();
// TODO - Make it clear in the UI that we're auto-adding this header
const canHaveBody =
!isWsRequest(requestType) && !["GET", "DELETE"].includes(method);
diff --git a/studio/src/utils/vendorify-traces.ts b/studio/src/utils/vendorify-traces.ts
index 0faceb78c..a2a28660a 100644
--- a/studio/src/utils/vendorify-traces.ts
+++ b/studio/src/utils/vendorify-traces.ts
@@ -176,7 +176,7 @@ const isOpenAIFetch = (span: OtelSpan) => {
};
// TODO - Make this a bit more robust?
-const isNeonFetch = (span: OtelSpan) => {
+export const isNeonFetch = (span: OtelSpan) => {
return !!span.attributes["http.request.header.neon-connection-string"];
};
@@ -190,7 +190,7 @@ const isAnthropicFetch = (span: OtelSpan) => {
}
};
-function getNeonSqlQuery(span: OtelSpan) {
+export function getNeonSqlQuery(span: OtelSpan) {
const body = getRequestBody(span);
// const body = getString(span.attributes["fpx.request.body"]);
if (!body) {
diff --git a/www/src/assets/blog/2024-10-03-neon-query-logs-expanded.png b/www/src/assets/blog/2024-10-03-neon-query-logs-expanded.png
new file mode 100644
index 000000000..74768ff90
Binary files /dev/null and b/www/src/assets/blog/2024-10-03-neon-query-logs-expanded.png differ
diff --git a/www/src/assets/blog/2024-10-03-neon-query-logs.png b/www/src/assets/blog/2024-10-03-neon-query-logs.png
new file mode 100644
index 000000000..e1da3ad9c
Binary files /dev/null and b/www/src/assets/blog/2024-10-03-neon-query-logs.png differ
diff --git a/www/src/content/blog/2024-10-03-neon-query-logs.mdx b/www/src/content/blog/2024-10-03-neon-query-logs.mdx
new file mode 100644
index 000000000..b8ae3d3d3
--- /dev/null
+++ b/www/src/content/blog/2024-10-03-neon-query-logs.mdx
@@ -0,0 +1,51 @@
+---
+title: "Neon Queries in Studio, O My!"
+description: Get rich context logs from Neon database queries automatically in the Studio UI
+slug: neon-query-logs-in-studio
+date: 2024-10-03
+author: Brett Beutell
+tags:
+ - Hono.js
+ - Neon
+ - HONC
+ - Postgres
+---
+
+import PackageManagers from "@/components/PackageManagers.astro";
+
+We're shipping a small but mighty feature in Fiberplane Studio that will allow you to view query logs from Neon directly in the Studio logs table.
+
+![Neon Query Logs UI](@/assets/blog/2024-10-03-neon-query-logs.png)
+
+## TL;DR
+
+You know how the browser console gives you a heads up when a network request errored, or a resource failed to load?
+
+I like that pattern. It gives you context of what's going on in your app, right alongside any info you chose to print.
+
+Well, in that vein, we're shipping an enhancement in Studio that will put metadata about Neon queries right in the logs table.
+
+When you click on a Neon entry in the logs table, you'll see an expanded view with the SQL query that was executed, the duration, and the row count.
+If there was an error, you'll see that too.
+
+![Expanded Neon Query Log](@/assets/blog/2024-10-03-neon-query-logs-expanded.png)
+
+No configuration necessary—if you're running Neon in a Hono app, you get some extra info without any work on your part.
+
+## This is only the beginning
+
+The logs table in Studio is getting richer by the day.
+
+With [plans](https://github.com/fiberplane/fpx/issues/292) to also hook into Drizzle's query logs, Fiberplane Studio will be able to render useful query logs for any database that Drizzle ORM supports.
+
+Are there any other items you think deserve to be rendered in the logs table? Let us know on [Discord](https://discord.gg/cqdY6SpfVR) or [GitHub](https://github.com/fiberplane/fpx/issues)!
+
+## Take it for a spin
+
+This new feature is well-suited for 🪿 [HONC](https://honc.dev) apps.
+
+If you want to give it ago, spin up a new Hono app with a Neon database using [the create-honc-app CLI](/blog/faster-neon-setup-create-honc-app), and try it out!
+
+
+
+🪿 Honc! Honc!