From 6b5bacf8a0f8dabe88898d25c5e5c64ff1b0f5c4 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:26:34 +0300 Subject: [PATCH 1/2] docs: document LLM setup on the AI Features page (#9121) Co-authored-by: Claude Opus 5 --- .../enterprise-features/ai-features.mdx | 176 ++++++++++++++++-- docs/self-hosting/configuration.mdx | 6 + .../configuration/environment-variables.mdx | 8 +- 3 files changed, 166 insertions(+), 24 deletions(-) diff --git a/docs/self-hosting/advanced/enterprise-features/ai-features.mdx b/docs/self-hosting/advanced/enterprise-features/ai-features.mdx index 6b0ce9fc45eb..98f2521f7c6b 100644 --- a/docs/self-hosting/advanced/enterprise-features/ai-features.mdx +++ b/docs/self-hosting/advanced/enterprise-features/ai-features.mdx @@ -1,16 +1,68 @@ --- title: "AI Features" -description: "Enable AI-powered helpers like survey translation and AI chart creation." +description: "Connect an LLM to your instance and enable AI-powered helpers like survey translation and AI chart creation." icon: "sparkles" sidebarTitle: "AI Features" --- -A single organization toggle unlocks AI-assisted survey translation and AI chart creation across the app. Requires `AI_PROVIDER`, `AI_MODEL`, and the matching provider configuration on the instance. +A single organization toggle unlocks AI-assisted survey translation and AI chart creation across the app. +Formbricks runs fine without AI — nothing is enabled until you connect a model. -## Kubernetes Helm + + AI features are part of the [Enterprise Edition](/self-hosting/advanced/license). See [AI + Features](/platform/features/ai-features) for what the toggle unlocks and how your data is handled. + -The Formbricks Helm chart can deploy a bundled Qwen/vLLM runtime for Smart functionality. This path is disabled -by default and requires GPU-capable Kubernetes nodes. +Wiring up an LLM takes two steps: + + + + Set `AI_PROVIDER`, `AI_MODEL`, and the credentials for that provider as environment variables, then + restart your containers. + + + In the app, go to **Settings → Organization → General → Smart functionality (AI)** and enable the toggle. + Only Owners and Managers can change it. + + + +## Choosing a provider + +`AI_PROVIDER` accepts four values. Set only the variables for the provider you use — the rest can be omitted. + +| `AI_PROVIDER` | Runs on | +| ------------------- | -------------------------------------------------- | +| `openai-compatible` | Any OpenAI-compatible `/v1` endpoint — Qwen/vLLM and friends. The self-hosted path. | +| `google` | Google Cloud, for Gemini models. | +| `azure` | Azure OpenAI / Foundry. | +| `aws` | Amazon Bedrock. | + +## OpenAI-compatible (self-hosted) + +The supported self-hosted path is Qwen served by vLLM behind an OpenAI-compatible `/v1` endpoint. Docker Compose +and the Helm chart can both deploy that runtime for you — both paths are disabled by default and require +GPU-capable hosts. + +### Docker Compose + +The Docker stack deploys the Qwen/vLLM runtime through an opt-in Compose profile. It needs a GPU-capable Docker +host with the NVIDIA Container Toolkit installed. + +```bash +COMPOSE_PROFILES=qwen +AI_PROVIDER=openai-compatible +AI_MODEL=qwen3-14b-awq +AI_OPENAI_COMPATIBLE_BASE_URL=http://vllm:8000/v1 +AI_OPENAI_COMPATIBLE_PROVIDER_NAME=vllm +AI_OPENAI_COMPATIBLE_SUPPORTS_STRUCTURED_OUTPUTS=1 +``` + +If you use the optional taxonomy service and want it to share the bundled Qwen runtime, start Docker Compose with +`COMPOSE_PROFILES=qwen,taxonomy` and point `TAXONOMY_LLM_BASE_URL` at `http://vllm:8000/v1`. + +### Kubernetes Helm + +The Formbricks Helm chart deploys the same runtime on GPU-capable Kubernetes nodes. ```yaml llm: @@ -24,27 +76,113 @@ variables. Set `llm.autoConfigureApp=false` if you want the chart to deploy Qwen/vLLM but prefer to configure the app provider manually. -## Docker Compose +### Your own endpoint -The Docker stack can deploy the same Qwen/vLLM runtime through an opt-in Compose profile. This path is disabled -by default and requires a GPU-capable Docker host with the NVIDIA Container Toolkit installed. +To use an endpoint you already run, do not enable the bundled runtime — keep the `qwen` Compose profile off, or +`llm.enabled=false` in Helm. Point `AI_OPENAI_COMPATIBLE_BASE_URL` at your endpoint and add +`AI_OPENAI_COMPATIBLE_API_KEY` if it requires one. + +`AI_PROVIDER` and `AI_MODEL` are always required. `AI_OPENAI_COMPATIBLE_BASE_URL` is the only variable specific +to this provider that you must set, and it has to be a valid HTTP(S) URL. + + + Use an `https://` endpoint whenever you set `AI_OPENAI_COMPATIBLE_API_KEY`. Formbricks accepts a plain `http://` + URL, so nothing stops you sending that key — and every prompt — in cleartext. Plain HTTP is only appropriate for + an endpoint that takes no credentials and is unreachable from outside your network, such as the bundled + `http://vllm:8000/v1` on the internal Compose network. + + +## Google Cloud ```bash -COMPOSE_PROFILES=qwen -AI_PROVIDER=openai-compatible -AI_MODEL=qwen3-14b-awq -AI_OPENAI_COMPATIBLE_BASE_URL=http://vllm:8000/v1 -AI_OPENAI_COMPATIBLE_PROVIDER_NAME=vllm -AI_OPENAI_COMPATIBLE_SUPPORTS_STRUCTURED_OUTPUTS=1 +AI_PROVIDER=google +AI_MODEL=gemini-3.5-flash +AI_GOOGLE_CLOUD_PROJECT=your-project-id +AI_GOOGLE_CLOUD_LOCATION=global ``` -If you use the optional taxonomy service and want it to share the bundled Qwen runtime, start Docker Compose with -`COMPOSE_PROFILES=qwen,taxonomy` and point `TAXONOMY_LLM_BASE_URL` at `http://vllm:8000/v1`. +Formbricks passes `AI_GOOGLE_CLOUD_LOCATION` through as given and enforces no list of its own: `us` and `eu` +are sent to their multi-region endpoints, and every other value — `global` and regional locations such as +`europe-west3` — uses the SDK default endpoint. Which locations actually serve your model depends on Google's +availability for that model and your serving mode, so check the +[model page](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/3-5-flash) before picking +one for data-residency reasons. `global` is the safe default. + +Credentials are optional. If the container already has Application Default Credentials, leave both credential +variables unset. Otherwise pick one: + +- `AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS` — mount the service account key file into the container and set this + to its path, for example `/run/secrets/google-cloud.json`. +- `AI_GOOGLE_CLOUD_CREDENTIALS_JSON` — the entire contents of that key file, as a single-line JSON string. + Formbricks parses this value, so paste the whole file: a truncated or hand-edited value fails as invalid JSON. + + + Formbricks uses Google Cloud naming here, even though the underlying SDK still talks to Vertex AI endpoints for + Gemini model access. + + +## Azure -## External Providers +```bash +AI_PROVIDER=azure +AI_MODEL=your-deployment-name +AI_AZURE_API_KEY=your-api-key +AI_AZURE_RESOURCE_NAME=your-resource +``` + +`AI_MODEL` is the deployment name, not the base model name. `AI_AZURE_API_VERSION` defaults to `v1`. + +Provide either `AI_AZURE_RESOURCE_NAME` or `AI_AZURE_BASE_URL` — the base URL wins when both are set. If you set +the base URL, end it at `/openai` and do not append `/v1`: the Azure SDK adds the version segment itself, so +`https://your-resource.openai.azure.com/openai/v1` produces requests against `/openai/v1/v1/…` and fails. + +```bash +# Equivalent to the resource name above +AI_AZURE_BASE_URL=https://your-resource.openai.azure.com/openai +``` -Keep `llm.enabled=false` when you use Google Vertex, Azure, AWS Bedrock, or your own OpenAI-compatible runtime. +## AWS Bedrock + +```bash +AI_PROVIDER=aws +AI_MODEL=eu.anthropic.claude-sonnet-4-5-20250929-v1:0 +AI_AWS_REGION=eu-central-1 +AI_AWS_ACCESS_KEY_ID=your-access-key-id +AI_AWS_SECRET_ACCESS_KEY=your-secret-access-key +# Only for temporary credentials +AI_AWS_SESSION_TOKEN=your-session-token +``` + +## External providers on Kubernetes + +Keep `llm.enabled=false` when you use Google Cloud, Azure, AWS Bedrock, or your own OpenAI-compatible runtime. Configure those providers with `deployment.env` in your Helm values or with environment variables in your deployment platform. -Read the full guide: [AI Features](/platform/features/ai-features). +## Verifying the setup + +Restart your containers after changing any of these variables, then open **Settings → Organization → General**. +While the instance is not configured, the AI toggle stays disabled and the page says so — the setting cannot be +turned on from the UI until the environment variables are in place. + + + An enabled toggle only means the variables are present and well-formed. Formbricks does not call your provider + to check it: an unreachable base URL, a wrong API key, or a deployment name that does not exist all pass this + check and fail on the first real request. + + +So finish by exercising the model once — translate a survey into a second language, or ask the AI chart builder +for a chart. If that request fails while the toggle is on, the credentials or the endpoint are wrong, not the +configuration shape. + +If the toggle stays disabled after a restart, check the app logs: + +```bash +docker compose logs formbricks | grep -i "ai" +``` + +The usual causes are `AI_MODEL` left unset, credentials that do not match the chosen `AI_PROVIDER`, or an +`AI_GOOGLE_CLOUD_CREDENTIALS_JSON` value that is not valid JSON. + +The full list of `AI_*` variables, including the optional ones, is in the [environment variables +reference](/self-hosting/configuration/environment-variables). diff --git a/docs/self-hosting/configuration.mdx b/docs/self-hosting/configuration.mdx index 143045aa096e..f81fed43daa1 100644 --- a/docs/self-hosting/configuration.mdx +++ b/docs/self-hosting/configuration.mdx @@ -55,6 +55,12 @@ Where Formbricks lives on the network, and how browsers get to it. Configure storage for survey images, file-upload answers and workspace assets. +## Connecting an LLM + + + Point the instance at Qwen/vLLM, Google Cloud, Azure or Bedrock, and turn AI on for your organization. + + ## Signing in Out of the box people sign in with an email address and a password. Point the instance at your own identity provider instead and sign-in follows the rules you already enforce there. diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx index a0de51780747..2b52081813d8 100644 --- a/docs/self-hosting/configuration/environment-variables.mdx +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -13,9 +13,7 @@ These variables are present inside your machine's docker-compose file. Restart t Formbricks v5 makes Hub part of the standard self-hosted runtime and changes how rate limiting is enforced. -For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-3.5-flash` together with Google Cloud credentials. `gemini-3.5-flash` must use `AI_GOOGLE_CLOUD_LOCATION=global`, `us`, or `eu`; keep regional locations such as `europe-west3` or `me-central2` only for models Google lists as supported there, such as `gemini-2.5-flash`. Formbricks uses Google Cloud naming here, even though the underlying SDK still talks to Vertex AI endpoints for Gemini model access. - -For `AI_PROVIDER=openai-compatible`, the LLM GA v1 self-hosted path is Qwen served by vLLM through an OpenAI-compatible `/v1` endpoint. Docker Compose users can enable the bundled Qwen/vLLM service with `COMPOSE_PROFILES=qwen`, or point `AI_OPENAI_COMPATIBLE_BASE_URL` at their own endpoint. Set only the variables for the provider you use; unused provider variables can be omitted. +For the `AI_*` variables, see [AI Features](/self-hosting/advanced/enterprise-features/ai-features) — it walks through each provider and the credentials it needs. Set only the variables for the provider you use; unused provider variables can be omitted. {/* prettier-ignore-start */} @@ -73,14 +71,14 @@ For `AI_PROVIDER=openai-compatible`, the LLM GA v1 self-hosted path is Qwen serv | AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `google`, `azure`, `openai-compatible`. | optional (required if AI is enabled) | | | AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | | | AI_GOOGLE_CLOUD_PROJECT | Google Cloud project ID for the `google` AI provider. | optional (required if `AI_PROVIDER=google`) | | -| AI_GOOGLE_CLOUD_LOCATION | Google Cloud location for `google` AI requests. For `gemini-3.5-flash`, use `global`, `us`, or `eu`. | optional (required if `AI_PROVIDER=google`) | | +| AI_GOOGLE_CLOUD_LOCATION | Google Cloud location for `google` AI requests. Passed through as given; `us` and `eu` use multi-region endpoints. `global` is the safe default. | optional (required if `AI_PROVIDER=google`) | | | AI_GOOGLE_CLOUD_CREDENTIALS_JSON | Optional service account credentials JSON override for the `google` AI provider. Omit when Application Default Credentials are available. | optional | | | AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS | Optional path to Google Application Default Credentials used by the `google` AI provider. | optional | | | AI_AWS_REGION | AWS region for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | | | AI_AWS_ACCESS_KEY_ID | AWS access key ID for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | | | AI_AWS_SECRET_ACCESS_KEY | AWS secret access key for Amazon Bedrock. | optional (required if `AI_PROVIDER=aws`) | | | AI_AWS_SESSION_TOKEN | AWS session token for Amazon Bedrock temporary credentials. | optional | | -| AI_AZURE_BASE_URL | Azure OpenAI / Foundry base URL. When set, this is preferred over `AI_AZURE_RESOURCE_NAME`. | optional (one of this or `AI_AZURE_RESOURCE_NAME` required if `AI_PROVIDER=azure`) | | +| AI_AZURE_BASE_URL | Azure OpenAI / Foundry base URL, ending at `/openai` — the SDK appends `/v1` itself. When set, this is preferred over `AI_AZURE_RESOURCE_NAME`. | optional (one of this or `AI_AZURE_RESOURCE_NAME` required if `AI_PROVIDER=azure`) | | | AI_AZURE_RESOURCE_NAME | Azure resource name used to assemble the Azure OpenAI URL. | optional (one of this or `AI_AZURE_BASE_URL` required if `AI_PROVIDER=azure`) | | | AI_AZURE_API_KEY | API key for Azure OpenAI / Foundry. | optional (required if `AI_PROVIDER=azure`) | | | AI_AZURE_API_VERSION | Azure API version for OpenAI-compatible calls. | optional | v1 | From 92a8bb6eade7a603443e17282e5ac72c6fb14f41 Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:34:11 +0000 Subject: [PATCH 2/2] feat: pipe response and survey context into feedback record metadata (#9111) --- .../app/api/v3/feedbackRecords/lib/schemas.ts | 17 +- .../lib/feedback-source/pipeline-handler.ts | 4 +- .../feedback-source/response-metadata.test.ts | 347 ++++++++++++++++++ .../lib/feedback-source/response-metadata.ts | 244 ++++++++++++ .../web/lib/feedback-source/transform.test.ts | 169 +++++++++ apps/web/lib/feedback-source/transform.ts | 43 ++- 6 files changed, 806 insertions(+), 18 deletions(-) create mode 100644 apps/web/lib/feedback-source/response-metadata.test.ts create mode 100644 apps/web/lib/feedback-source/response-metadata.ts diff --git a/apps/web/app/api/v3/feedbackRecords/lib/schemas.ts b/apps/web/app/api/v3/feedbackRecords/lib/schemas.ts index 20a61b707c63..031f83830be1 100644 --- a/apps/web/app/api/v3/feedbackRecords/lib/schemas.ts +++ b/apps/web/app/api/v3/feedbackRecords/lib/schemas.ts @@ -381,7 +381,22 @@ export const ZV3FeedbackRecordCreateBodyFields = z.object({ message: `must serialize to at most ${MAX_METADATA_BYTES} bytes`, }) .optional() - .describe("Additional context (device, tags, etc.)."), + // This one description is the whole story for three MCP tools (create, batch create, update), + // which inherit it from here, so it has to carry the traps as well as the shape. The filtering + // one is the expensive one to learn late: an agent that assumes `metadata` is queryable will + // promise a breakdown it cannot produce. + .describe( + "Arbitrary context stored with the record and returned with equivalent values (key order and " + + "number formatting are normalized): the dimensions you want to " + + "group or segment by later, such as channel, device, browser, OS, country, referrer, " + + "campaign, plan or tags. Values may be strings, numbers, booleans, null, or nested objects " + + "and arrays. Use snake_case keys and keep them stable across records, since the key is what " + + "a chart groups by. NOT filterable or searchable through this API — metadata is read back " + + "with a record, so narrowing by a metadata value means fetching and filtering client-side. " + + "On update the whole object is REPLACED, not merged, so send every key you want to keep. " + + "Avoid personal data: it is stored unredacted (and the Formbricks survey pipeline repeats " + + "its own metadata on every record of a submission)." + ), }); /** diff --git a/apps/web/lib/feedback-source/pipeline-handler.ts b/apps/web/lib/feedback-source/pipeline-handler.ts index b077b01fa4a9..3d1452e75be0 100644 --- a/apps/web/lib/feedback-source/pipeline-handler.ts +++ b/apps/web/lib/feedback-source/pipeline-handler.ts @@ -34,7 +34,7 @@ const logFailedRecords = (feedbackSourceId: string, failures: TReconcileFailure[ const processFeedbackSource = async ( feedbackSource: TFeedbackSourceWithMappings, response: TResponse, - survey: Pick, + survey: Pick, workspaceId: string ): Promise => { const feedbackRecords = transformResponseToFeedbackRecords( @@ -106,7 +106,7 @@ const processFeedbackSource = async ( */ export const handleFeedbackSourcePipeline = async ( response: TResponse, - survey: Pick, + survey: Pick, workspaceId: string ): Promise => { try { diff --git a/apps/web/lib/feedback-source/response-metadata.test.ts b/apps/web/lib/feedback-source/response-metadata.test.ts new file mode 100644 index 000000000000..dfb1734f1d6f --- /dev/null +++ b/apps/web/lib/feedback-source/response-metadata.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, test } from "vitest"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import { + HUB_METADATA_FIELDS, + type TMetadataContext, + buildResponseMetadata, + projectMetadataFields, + stripUrlQuery, +} from "./response-metadata"; + +type TMetadataResponse = TMetadataContext["response"]; + +const buildResponse = (overrides: Partial = {}): TMetadataResponse => ({ + meta: {}, + finished: true, + ttc: {}, + ...overrides, +}); + +const linkSurvey: Pick = { type: "link" }; + +const fullMeta = { + source: "link", + url: "https://app.example.com/s/abc?token=secret&utm_source=newsletter#question-2", + userAgent: { browser: "Chrome", os: "macOS", device: "desktop" }, + country: "PT", + action: "Clicked pricing CTA", + // Present on every IP-capturing survey's response and must never be published. + ipAddress: "203.0.113.7", +}; + +describe("stripUrlQuery", () => { + test("reduces an absolute url to origin and path", () => { + expect(stripUrlQuery("https://app.example.com/s/abc?token=secret#question-2")).toBe( + "https://app.example.com/s/abc" + ); + }); + + test("leaves a url that carries no query or fragment untouched", () => { + expect(stripUrlQuery("https://app.example.com/pricing")).toBe("https://app.example.com/pricing"); + }); + + test("drops embedded credentials", () => { + expect(stripUrlQuery("https://user:pass@app.example.com/s/abc")).toBe("https://app.example.com/s/abc"); + }); + + test("cuts the query off a value that is not an absolute url", () => { + // The scheme-less form cannot be parsed, and passing it through would leak the token this + // helper exists to remove. + expect(stripUrlQuery("app.example.com/s/abc?token=secret")).toBe("app.example.com/s/abc"); + }); + + test("cuts the query off a non-web scheme", () => { + expect(stripUrlQuery("myapp://survey/abc?token=secret")).toBe("myapp://survey/abc"); + }); + + test.each([ + // `user:pass@host` parses as a URL whose protocol is `user:`, so it dodges the origin branch — + // the fallback has to drop the credentials itself. + ["scheme-less credentials", "user:pass@app.example.com/p?token=1", "app.example.com/p"], + ["credentials on a custom scheme", "myapp://u:p@host/x?t=1", "myapp://host/x"], + // A network-path reference: new URL() rejects it without a base, so only the fallback can + // strip these — and its userinfo cut has to see past the leading slashes. + ["credentials on a protocol-relative url", "//user:pass@host/path?token=1", "//host/path"], + ["credentials with nothing after them", "user:pass@", undefined], + ])("drops userinfo the origin branch never saw (%s)", (_label, input, expected) => { + expect(stripUrlQuery(input)).toBe(expected); + }); + + test.each([ + // The personal-link token is the credential itself, and stripping the query does not touch it. + ["personal link", "https://app.example.com/c/eyJhbGci.tok.sig?foo=1", "https://app.example.com/c"], + ["personal link, no query", "https://app.example.com/c/eyJhbGci.tok.sig", "https://app.example.com/c"], + [ + "an ordinary survey path is untouched", + "https://app.example.com/s/cm123", + "https://app.example.com/s/cm123", + ], + // The fallback shape: never produced by the SDK (which sends an absolute href), but `meta.url` + // is client-supplied, so the helper's contract has to hold on this path too. + ["protocol-relative personal link", "//app.example.com/c/eyJhbGci.tok.sig?foo=1", "//app.example.com/c"], + ["scheme-less personal link", "app.example.com/c/eyJhbGci.tok.sig", "app.example.com/c"], + ["a /c segment that is not the first is left alone", "//host/a/c/keep", "//host/a/c/keep"], + ])("drops the personal-link token (%s)", (_label, input, expected) => { + expect(stripUrlQuery(input)).toBe(expected); + }); + + test("drops userinfo up to the last @, not the first", () => { + // Cutting at the first `@` would publish the tail of the password. + expect(stripUrlQuery("//user:p@ss@host/path?token=1")).toBe("//host/path"); + }); + + test.each([ + ["empty", ""], + ["whitespace", " "], + ["query only", "?token=secret"], + ])("returns undefined for a %s value", (_label, input) => { + expect(stripUrlQuery(input)).toBeUndefined(); + }); +}); + +describe("buildResponseMetadata", () => { + test("publishes the full response and survey context", () => { + // Asserted with toEqual, not toMatchObject: this is the published payload, so a newly added + // key has to be seen and decided on here rather than shipping unnoticed. + expect( + buildResponseMetadata( + buildResponse({ + meta: fullMeta, + finished: true, + ttc: { _total: 45_500 }, + endingId: "ending-1", + }), + { type: "app" } + ) + ).toEqual({ + source: "link", + url: "https://app.example.com/s/abc", + browser: "Chrome", + os: "macOS", + device: "desktop", + country: "PT", + action: "Clicked pricing CTA", + finished: true, + duration_seconds: 46, + ending_id: "ending-1", + survey_type: "app", + }); + }); + + test("never publishes the respondent's IP address", () => { + const result = buildResponseMetadata(buildResponse({ meta: fullMeta }), linkSurvey); + + expect(Object.keys(result)).not.toContain("ipAddress"); + expect(Object.keys(result)).not.toContain("ip_address"); + expect(Object.values(result)).not.toContain(fullMeta.ipAddress); + }); + + test("falls back to row context when the response carries no meta", () => { + // meta defaults to {} in Prisma, so this is the shape of a link response with no tracking. + expect(buildResponseMetadata(buildResponse({ meta: {} }), linkSurvey)).toEqual({ + finished: true, + survey_type: "link", + }); + }); + + test("omits blank values instead of publishing empty keys", () => { + expect( + buildResponseMetadata( + buildResponse({ + meta: { + source: "", + url: "", + country: " ", + action: "", + userAgent: { browser: "", os: "", device: "" }, + }, + }), + linkSurvey + ) + ).toEqual({ finished: true, survey_type: "link" }); + }); + + test("returns nothing for a row that carries no context at all", () => { + // Legacy rows predate several of these fields; the transform relies on an empty result to omit + // the metadata key entirely rather than storing {}. + const result = buildResponseMetadata( + { meta: undefined, finished: undefined, ttc: undefined } as unknown as TMetadataResponse, + {} as Pick + ); + + expect(result).toEqual({}); + }); + + describe("values the column can hold but the type does not describe", () => { + // Response.meta is a Prisma `Json` column and stored rows are never re-validated on read, so + // these shapes are reachable in production even though TResponseMeta forbids them. A throw here + // aborts the whole transform and the caller's catch drops the response's records silently. + test("treats a null value as absent", () => { + const result = buildResponseMetadata( + buildResponse({ meta: { source: "link", action: null } as never }), + linkSurvey + ); + + expect(result).not.toHaveProperty("action"); + expect(result.source).toBe("link"); + }); + + test("drops a value that is not a scalar, and passes a stray scalar through", () => { + const result = buildResponseMetadata( + buildResponse({ meta: { source: 42, url: { nested: true }, country: ["PT"] } as never }), + linkSurvey + ); + + // A number is a legal JSONB scalar, so publishing it loses nothing; an object or array is + // what the metadata contract cannot carry. + expect(result.source).toBe(42); + expect(result).not.toHaveProperty("url"); + expect(result).not.toHaveProperty("country"); + }); + + test("survives a meta object that is null outright", () => { + expect(() => buildResponseMetadata(buildResponse({ meta: null as never }), linkSurvey)).not.toThrow(); + }); + }); + + describe("bounds", () => { + test("truncates oversized values so an inflated meta cannot fail the Hub create", () => { + const result = buildResponseMetadata( + buildResponse({ + meta: { + source: "s".repeat(400), + url: `https://app.example.com/${"p".repeat(900)}`, + }, + }), + linkSurvey + ); + + expect(result.source).toHaveLength(256); + expect(result.url).toHaveLength(512); + }); + + test("never cuts a surrogate pair in half", () => { + // 255 single-unit characters plus one emoji is 257 UTF-16 code units, so the 256 cap lands + // between the emoji's two halves. + const result = buildResponseMetadata( + buildResponse({ meta: { source: `${"a".repeat(255)}\u{1F600}` } }), + linkSurvey + ); + + expect(result.source).toHaveLength(255); + // A lone surrogate is rejected on the jsonb insert just like a NUL byte, so the whole + // submission's records would never be published. + expect(String(result.source)).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/); + }); + + test("keeps a multi-byte character that fits within the cap", () => { + expect( + buildResponseMetadata(buildResponse({ meta: { source: "feedback \u{1F600}" } }), linkSurvey).source + ).toBe("feedback \u{1F600}"); + }); + + test("does not leave trailing whitespace where the cut landed", () => { + expect( + buildResponseMetadata(buildResponse({ meta: { source: `${"a".repeat(255)} tail` } }), linkSurvey) + .source + ).toBe("a".repeat(255)); + }); + + test("strips NUL bytes, which Hub cannot store", () => { + expect( + buildResponseMetadata(buildResponse({ meta: { source: "li\u0000nk" } }), linkSurvey).source + ).toBe("link"); + }); + + test("omits a value that is nothing but NUL bytes", () => { + expect( + buildResponseMetadata(buildResponse({ meta: { source: "\u0000" } }), linkSurvey) + ).not.toHaveProperty("source"); + }); + }); + + describe("duration_seconds", () => { + test("converts the total time-to-complete from milliseconds", () => { + expect( + buildResponseMetadata(buildResponse({ ttc: { _total: 45_500 } }), linkSurvey).duration_seconds + ).toBe(46); + }); + + test("publishes a duration of exactly seven days", () => { + // The cap is inclusive: a week is a plausible longest-lived link-survey tab, noise starts + // beyond it. + expect( + buildResponseMetadata(buildResponse({ ttc: { _total: 7 * 24 * 60 * 60 * 1000 } }), linkSurvey) + .duration_seconds + ).toBe(604_800); + }); + + test("publishes a zero duration", () => { + // Zero is a measurement, not a missing value — an omit-on-falsy check would drop it. + expect(buildResponseMetadata(buildResponse({ ttc: { _total: 0 } }), linkSurvey).duration_seconds).toBe( + 0 + ); + }); + + test.each([ + ["no _total key", {}], + ["a negative total", { _total: -1 }], + ["a non-finite total", { _total: Number.NaN }], + ["a total beyond a week", { _total: 8 * 24 * 60 * 60 * 1000 }], + ])("omits the duration for %s", (_label, ttc) => { + expect(buildResponseMetadata(buildResponse({ ttc }), linkSurvey)).not.toHaveProperty( + "duration_seconds" + ); + }); + }); +}); + +describe("HUB_METADATA_FIELDS", () => { + test("publishes exactly the reviewed allowlist", () => { + // Adding a field to the catalog is a privacy decision (see the module comment), so it has to be + // made here too. `ipAddress` is absent by construction and must stay absent. + expect(HUB_METADATA_FIELDS.filter((field) => field.enabled).map((field) => field.key)).toEqual([ + "source", + "url", + "browser", + "os", + "device", + "country", + "action", + "finished", + "duration_seconds", + "ending_id", + "survey_type", + ]); + }); + + test("names every field in snake_case, as Hub metadata keys are conventionally written", () => { + const offenders = HUB_METADATA_FIELDS.filter((field) => !/^[a-z][a-z0-9_]*$/.test(field.key)); + expect(offenders.map((field) => field.key)).toEqual([]); + }); +}); + +describe("projectMetadataFields", () => { + test("skips a field that has been withdrawn", () => { + // Driven through an ad-hoc table rather than by mutating HUB_METADATA_FIELDS, which other callers + // share: the point is that flipping `enabled` is all it takes to stop publishing a field. + const result = projectMetadataFields( + [ + { key: "kept", enabled: true, read: () => "published" }, + { key: "withdrawn", enabled: false, read: () => "should not appear" }, + ], + { response: buildResponse(), survey: linkSurvey } + ); + + expect(result).toEqual({ kept: "published" }); + }); + + test("applies a field's own maxLength ahead of the default", () => { + const result = projectMetadataFields( + [{ key: "roomy", enabled: true, maxLength: 400, read: () => "x".repeat(500) }], + { response: buildResponse(), survey: linkSurvey } + ); + + expect(result.roomy).toHaveLength(400); + }); +}); diff --git a/apps/web/lib/feedback-source/response-metadata.ts b/apps/web/lib/feedback-source/response-metadata.ts new file mode 100644 index 000000000000..8565c330240a --- /dev/null +++ b/apps/web/lib/feedback-source/response-metadata.ts @@ -0,0 +1,244 @@ +import "server-only"; +import type { TResponse } from "@formbricks/types/responses"; +import type { TSurvey } from "@formbricks/types/surveys/types"; + +/** + * Response- and survey-level context published on every FeedbackRecord's `metadata` (ENG-1554). + * + * Records used to carry only the answer itself, so Hub held no dimension to slice a dashboard by — + * no channel, no device, no completion state. Everything below already existed on the response and + * was simply dropped on the floor. + * + * Two rules govern what may be added here: + * + * 1. It is an allowlist, never a spread of `response.meta`. `ipAddress` is the reason: it lives on + * the same object, it is personal data under GDPR, and Hub applies no validation or redaction of + * its own. A spread would publish it the moment a survey enables IP capture. Absence by + * construction is the guard — there is no filter to forget. + * + * The other response fields left out, so a reader can tell decided from forgotten: + * `contactAttributes` (arbitrary customer-set values — the richest dimension set here, and the + * one most likely to carry personal data, so it needs its own decision rather than riding along), + * `tags` (curated in the UI after submission, so at `responseFinished` they are near-always empty + * and would publish a stale value), `variables` (per-survey and unbounded), and `displayId` / + * `singleUseId` / `updatedAt` (internal plumbing, and `singleUseId` is itself a link token). + * 2. Values are bounded here. `source`, `url` and `action` are client-supplied on the public + * response endpoint (`ZResponseInput.meta` declares no maximum lengths) and Hub caps only the + * total request body at 512 KiB, so an oversized value would fail the create and silently cost + * the response its records. + * + * The shape below deliberately mirrors `RESERVED_FIELD_CATALOG` on `epic/embedded-data-v1` (a key, + * a typed reader, a publish decision per field). When that lands, this table becomes a projection + * of the catalog rather than a second list of the same fields — see ENG-2538, which exists because + * private copies of that list drifted from it. + */ + +/** + * Metadata values are scalars only, which keeps sanitation total — no recursion, no nested JSON. + * + * `null` is in the union because `Response.meta` is a Prisma `Json` column: its Zod type describes + * what the API writes, not what the table holds, and stored rows are never re-validated on read. A + * reader can therefore surface a `null` — or a value of the wrong type entirely — where the type + * says `string | undefined`. + */ +type TMetadataValue = string | number | boolean | null | undefined; + +export type TResponseMetadata = Record; + +export type TMetadataContext = { + response: Pick; + survey: Pick; +}; + +export type TMetadataFieldSpec = { + /** snake_case key as it appears in the Hub record's metadata object. */ + readonly key: string; + /** + * Whether the field is published. Every field ships enabled; the flag exists so withdrawing one + * (a privacy decision, a customer request) is a one-word edit to this table rather than a change + * to the projection below, and so the epic's `privacy: "drop"` verdicts have somewhere to land. + */ + readonly enabled: boolean; + /** Overrides MAX_METADATA_TEXT_LENGTH for string values. */ + readonly maxLength?: number; + readonly read: (context: TMetadataContext) => TMetadataValue; +}; + +const MAX_METADATA_TEXT_LENGTH = 256; +/** URLs are legitimately longer than other values, even after the query string is stripped. */ +const MAX_METADATA_URL_LENGTH = 512; +/** + * Per-element `ttc` is clamped to 24h at the response boundary (ENG-1083), but stored rows keep the + * unbounded schema so historical data still parses — and `_total` sums every element. A duration + * past a week is noise rather than a measurement, and omitting it beats publishing a number that + * would skew an average silently. + */ +const MAX_DURATION_SECONDS = 7 * 24 * 60 * 60; +/** + * A high surrogate as the final UTF-16 code unit — the signature of a cut that split a pair. A + * complete pair ends on its LOW half, so this matches only the orphaned case. + */ +const TRAILING_HIGH_SURROGATE = /[\uD800-\uDBFF]$/; +/** + * The personal-link route's token segment (`apps/web/app/c/[jwt]`). Stripping the query is not + * enough there: the JWT *is* the authorization to answer as that contact, and it sits in the path, + * so `origin + pathname` would move a live credential into a second datastore. It is also unique + * per recipient, which would give `url` unbounded cardinality precisely where a dashboard wants a + * dimension. Keep the route, drop the secret. + */ +const PERSONAL_LINK_TOKEN_PATH = /^(\/c)\/[^/]+/; +/** + * The same token, for the fallback's input shape. A parsed `pathname` starts at `/`, but a value + * that never parsed still carries its scheme and authority, so the route has to be matched after + * them — and only as the FIRST path segment, so an unrelated `/a/c/x` is left alone. + */ +const PERSONAL_LINK_TOKEN_URL = /^((?:[a-z][a-z0-9+.-]*:)?\/\/[^/]*|[^/]*)(\/c)\/[^/]+/i; + +/** Userinfo up to the LAST `@` before the path, so `u:p@ss@host` cannot leak `ss`. */ +const LEADING_USERINFO = /^((?:[a-z][a-z0-9+.-]*:)?\/\/)?[^/]*@/i; + +/** + * Reduce a URL to origin + path. + * + * Query strings on survey URLs carry recovery tokens, verified emails and prefilled answers, so the + * query is the part that must not leave the product. Anything that is not an absolute http(s) URL + * still gets cut at the first `?` or `#`: a scheme-less value like `app.example.com/p?token=…` + * cannot be parsed, and passing it through unchanged would leak exactly what this strips. + */ +export const stripUrlQuery = (rawUrl: string): string | undefined => { + const trimmed = rawUrl.trim(); + if (!trimmed) return undefined; + + try { + const parsed = new URL(trimmed); + // `origin` also drops any embedded credentials (https://user:pass@host). + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return `${parsed.origin}${parsed.pathname.replace(PERSONAL_LINK_TOKEN_PATH, "$1")}`; + } + } catch { + // Not an absolute URL — fall through to the textual cut below. + } + + // The origin branch never ran, so credentials have not been dropped — and this path is easy to + // reach with them: `user:pass@host/p` parses as a URL whose protocol is `user:`, and the + // protocol-relative `//user:pass@host/p` cannot be parsed without a base, so both skip the + // branch above. Cut the query first, then remove any userinfo, keeping a `scheme://` or bare + // `//` prefix when one is present. + const cut = trimmed + .split(/[?#]/)[0] + .replace(LEADING_USERINFO, "$1") + .replace(PERSONAL_LINK_TOKEN_URL, "$1$2") + .trim(); + + return cut || undefined; +}; + +const readDurationSeconds = (ttc: TResponse["ttc"]): number | undefined => { + const total = ttc?._total; + if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return undefined; + + const seconds = Math.round(total / 1000); + return seconds <= MAX_DURATION_SECONDS ? seconds : undefined; +}; + +/** + * The published field set. `ipAddress` is absent deliberately and must stay absent — see rule 1 in + * the module comment. Readers are optional-chained throughout: stored rows predate several of these + * fields, and `meta` defaults to `{}` in Prisma. + */ +export const HUB_METADATA_FIELDS: readonly TMetadataFieldSpec[] = [ + { key: "source", enabled: true, read: ({ response }) => response.meta?.source }, + { + key: "url", + enabled: true, + maxLength: MAX_METADATA_URL_LENGTH, + read: ({ response }) => { + const url = response.meta?.url; + return typeof url === "string" ? stripUrlQuery(url) : undefined; + }, + }, + { key: "browser", enabled: true, read: ({ response }) => response.meta?.userAgent?.browser }, + { key: "os", enabled: true, read: ({ response }) => response.meta?.userAgent?.os }, + { key: "device", enabled: true, read: ({ response }) => response.meta?.userAgent?.device }, + { key: "country", enabled: true, read: ({ response }) => response.meta?.country }, + { key: "action", enabled: true, read: ({ response }) => response.meta?.action }, + { + key: "finished", + enabled: true, + read: ({ response }) => (typeof response.finished === "boolean" ? response.finished : undefined), + }, + { key: "duration_seconds", enabled: true, read: ({ response }) => readDurationSeconds(response.ttc) }, + // Which ending the respondent reached — the branch they came out of. Bounded per survey and + // author-defined, so it groups cleanly. + { key: "ending_id", enabled: true, read: ({ response }) => response.endingId }, + { key: "survey_type", enabled: true, read: ({ survey }) => survey.type }, +]; + +/** + * Narrow one read value to something Hub can store, or drop it. + * + * Takes `unknown` rather than TMetadataValue on purpose: the readers are typed against + * `TResponseMeta`, which describes what the API writes into a `Json` column rather than what the + * column holds. A throw here is not a local failure — it aborts the whole transform, and the + * caller's catch turns that into a response whose records are silently never published. + */ +const sanitizeValue = (value: unknown, maxLength: number): string | number | boolean | undefined => { + if (value === undefined || typeof value === "boolean") return value; + + if (typeof value === "number") return Number.isFinite(value) ? value : undefined; + + // Catches a stored null (typeof null === "object") as well as an object or array, neither of + // which the scalar-only metadata contract can carry. + if (typeof value !== "string") return undefined; + + // NUL bytes are the one input Hub cannot store: its validator skips non-string kinds, so the + // jsonb insert reaches Postgres and fails as a 500 rather than a rejected field. + const cleaned = value.replaceAll("\u0000", "").trim(); + if (!cleaned) return undefined; + if (cleaned.length <= maxLength) return cleaned; + + // maxLength counts UTF-16 code units, so the cut can land between the halves of a surrogate + // pair — and a lone surrogate is rejected on the jsonb insert exactly like a NUL byte, with the + // same silently-dropped-records outcome. The caller picks the offset by choosing the value's + // length, so this is reachable on purpose and not only by accident. + const truncated = cleaned.slice(0, maxLength); + const whole = TRAILING_HIGH_SURROGATE.test(truncated) ? truncated.slice(0, -1) : truncated; + + // The cut can land mid-word and leave trailing space the pre-truncation trim never saw. + return whole.trimEnd() || undefined; +}; + +/** + * Read a field table into a flat metadata object, dropping every value that is absent, empty or + * unrepresentable. + * + * Separate from the table so the mechanism can be proven against an arbitrary table — a disabled + * field, a field with its own maxLength — without mutating the module-level catalog other callers + * share. + */ +export const projectMetadataFields = ( + fields: readonly TMetadataFieldSpec[], + context: TMetadataContext +): TResponseMetadata => { + const metadata: TResponseMetadata = {}; + + for (const field of fields) { + if (!field.enabled) continue; + + const value = sanitizeValue(field.read(context), field.maxLength ?? MAX_METADATA_TEXT_LENGTH); + if (value !== undefined) metadata[field.key] = value; + } + + return metadata; +}; + +/** + * Build the metadata object shared by every FeedbackRecord of one response. + * + * Called once per response, not per record: the result is spread into each record by + * `buildBaseFields`, so a submission's records agree on their context by construction. + */ +export const buildResponseMetadata = ( + response: TMetadataContext["response"], + survey: TMetadataContext["survey"] +): TResponseMetadata => projectMetadataFields(HUB_METADATA_FIELDS, { response, survey }); diff --git a/apps/web/lib/feedback-source/transform.test.ts b/apps/web/lib/feedback-source/transform.test.ts index b2a7a24031aa..3c0736e4da71 100644 --- a/apps/web/lib/feedback-source/transform.test.ts +++ b/apps/web/lib/feedback-source/transform.test.ts @@ -28,6 +28,7 @@ const bilingualLanguages = [ const mockSurvey = { id: "survey-1", name: "Product Feedback", + type: "link", blocks: [ { elements: [ @@ -48,6 +49,17 @@ const mockSurvey = { const mockTenantId = "cmp2f6428000504la7iyh87h1"; +// Populated the way the public response endpoint populates it: source/url/action come from the +// client, userAgent/country/ipAddress are derived server-side. +const mockMeta = { + source: "link", + url: "https://app.example.com/s/survey-1?token=secret", + userAgent: { browser: "Chrome", os: "macOS", device: "desktop" }, + country: "PT", + action: "Clicked pricing CTA", + ipAddress: "203.0.113.7", +}; + const mockResponse = { id: "resp-1", createdAt: NOW, @@ -61,6 +73,9 @@ const mockResponse = { }, language: "en", contact: { userId: "user-42" }, + finished: true, + ttc: { "el-text": 4_000, _total: 45_500 }, + meta: mockMeta, } as unknown as TResponse; const createMapping = ( @@ -1064,4 +1079,158 @@ describe("transformResponseToFeedbackRecords", () => { }); }); }); + + describe("response metadata (ENG-1554)", () => { + // One survey covering all four record-building paths, so the merge invariant below is asserted + // against every one of them rather than only the generic case. + const everyPathSurvey = { + id: "survey-1", + name: "Every Path", + type: "app", + blocks: [ + { + elements: [ + { id: "el-text", type: "openText", headline: { default: "How can we improve?" } }, + { + id: "el-matrix", + type: "matrix", + headline: { default: "Rate each feature" }, + rows: [{ id: "row-1", label: { default: "Speed" } }], + columns: [{ id: "col-1", label: { default: "Good" } }], + }, + { + id: "el-ranking", + type: "ranking", + headline: { default: "Rank these" }, + choices: [ + { id: "ch-1", label: { default: "Reports" } }, + { id: "ch-2", label: { default: "Alerts" } }, + ], + }, + { id: "el-multi", type: "multipleChoiceMulti", headline: { default: "Select features" } }, + ], + }, + ], + } as unknown as TSurvey; + + const everyPathResponse = { + id: "resp-every-path", + createdAt: NOW, + data: { + "el-text": "Great product!", + "el-matrix": { Speed: "Good" }, + "el-ranking": ["Alerts", "Reports"], + "el-multi": ["feat-a", "feat-b"], + }, + language: "default", + contact: { userId: "user-42" }, + finished: true, + ttc: { _total: 45_500 }, + meta: mockMeta, + } as unknown as TResponse; + + const everyPathMappings = [ + createMapping({ elementId: "el-text", hubFieldType: "text" }), + createMapping({ elementId: "el-matrix", hubFieldType: "categorical" }), + createMapping({ elementId: "el-ranking", hubFieldType: "categorical" }), + createMapping({ elementId: "el-multi", hubFieldType: "categorical" }), + ]; + + const sharedContext = { + source: "link", + url: "https://app.example.com/s/survey-1", + browser: "Chrome", + os: "macOS", + device: "desktop", + country: "PT", + action: "Clicked pricing CTA", + finished: true, + duration_seconds: 46, + survey_type: "app", + }; + + test("publishes the response context on a single-value record", () => { + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + + const result = transformResponseToFeedbackRecords( + everyPathResponse, + everyPathSurvey, + mappings, + mockTenantId + ); + + expect(result).toHaveLength(1); + // The generic path published no metadata at all before ENG-1554. + expect(result[0].metadata).toEqual(sharedContext); + }); + + test("keeps question_type alongside the response context on every expanded record", () => { + const result = transformResponseToFeedbackRecords( + everyPathResponse, + everyPathSurvey, + everyPathMappings, + mockTenantId + ); + + // Each composite path sets `metadata` after spreading baseFields, so a missing merge silently + // drops the response context from exactly these records. + expect(result.length).toBeGreaterThan(4); + for (const record of result) { + expect(record.metadata).toMatchObject(sharedContext); + } + + const byField = (id: string) => result.find((record) => record.field_id?.startsWith(id))?.metadata; + expect(byField("el-matrix")).toMatchObject({ question_type: "matrix" }); + expect(byField("el-ranking")).toMatchObject({ question_type: "ranking", total_items: 2 }); + expect(byField("el-multi")).toMatchObject({ question_type: "multipleChoiceMulti" }); + expect(byField("el-text")).not.toHaveProperty("question_type"); + }); + + test("never publishes the respondent's IP address on any record", () => { + const result = transformResponseToFeedbackRecords( + everyPathResponse, + everyPathSurvey, + everyPathMappings, + mockTenantId + ); + + for (const record of result) { + expect(Object.keys(record.metadata ?? {})).not.toContain("ipAddress"); + expect(Object.values(record.metadata ?? {})).not.toContain(mockMeta.ipAddress); + } + }); + + test("strips the query string from the published url", () => { + const result = transformResponseToFeedbackRecords( + everyPathResponse, + everyPathSurvey, + everyPathMappings, + mockTenantId + ); + + // The survey url carries recovery tokens and prefilled answers in its query. + for (const record of result) { + expect(record.metadata?.url).toBe("https://app.example.com/s/survey-1"); + } + }); + + test("omits metadata entirely when the response carries no context", () => { + // Hub stores metadata nullable and compares it byte-wise, so sending {} would count as a + // change and fire a pointless feedback_record.updated webhook. + const bareResponse = { + id: "resp-bare", + createdAt: NOW, + data: { "el-text": "Great product!" }, + language: "default", + meta: {}, + } as unknown as TResponse; + const bareSurvey = { ...everyPathSurvey, type: undefined } as unknown as TSurvey; + const mappings = [createMapping({ elementId: "el-text", hubFieldType: "text" })]; + + const result = transformResponseToFeedbackRecords(bareResponse, bareSurvey, mappings, mockTenantId); + + expect(result).toHaveLength(1); + expect(result[0]).not.toHaveProperty("metadata"); + }); + }); }); diff --git a/apps/web/lib/feedback-source/transform.ts b/apps/web/lib/feedback-source/transform.ts index 3e38cb0efb1d..43f543738f1b 100644 --- a/apps/web/lib/feedback-source/transform.ts +++ b/apps/web/lib/feedback-source/transform.ts @@ -15,6 +15,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation"; import { getLanguageCode, getLocalizedValue } from "@/lib/i18n/utils"; import { getElementsFromBlocks } from "@/lib/survey/utils"; import type { FeedbackRecordCreateParams } from "@/modules/hub"; +import { type TResponseMetadata, buildResponseMetadata } from "./response-metadata"; const getHeadlineFromElement = (element?: TSurveyElement): string => { if (!element?.headline) return "Untitled"; @@ -158,22 +159,32 @@ type BaseRecordFields = Pick< > & { language?: string; user_id?: string; + metadata?: TResponseMetadata; }; const buildBaseFields = ( response: TResponse, - survey: Pick, + survey: Pick, tenantId: string -): BaseRecordFields => ({ - collected_at: getCollectedAt(response), - source_type: "formbricks_survey", - submission_id: response.id, - tenant_id: tenantId, - source_id: survey.id, - source_name: survey.name, - ...(response.language && response.language !== "default" ? { language: response.language } : {}), - ...(response.contact?.userId ? { user_id: response.contact.userId } : {}), -}); +): BaseRecordFields => { + // Built once per response and shared by every record of the submission (ENG-1554). + const metadata = buildResponseMetadata(response, survey); + + return { + collected_at: getCollectedAt(response), + source_type: "formbricks_survey", + submission_id: response.id, + tenant_id: tenantId, + source_id: survey.id, + source_name: survey.name, + ...(response.language && response.language !== "default" ? { language: response.language } : {}), + ...(response.contact?.userId ? { user_id: response.contact.userId } : {}), + // Omitted rather than sent as {}: Hub stores metadata nullable and compares it byte-wise, so an + // empty object on a record that has none would count as a change and fire a pointless + // feedback_record.updated webhook. + ...(Object.keys(metadata).length > 0 ? { metadata } : {}), + }; +}; const expandMatrixToRecords = ( element: TSurveyMatrixElement, @@ -213,7 +224,9 @@ const expandMatrixToRecords = ( field_label: getChoiceLabel(row, "default"), field_group_id: element.id, field_group_label: groupLabel, - metadata: { question_type: "matrix" }, + // Spread the shared response context first: this property overrides the one in ...baseFields, + // so anything not re-stated here is dropped from the record. + metadata: { ...baseFields.metadata, question_type: "matrix" }, ...valueFields, ...(matchedColumn.valueId ? { value_id: matchedColumn.valueId } : {}), }); @@ -250,7 +263,7 @@ const expandRankingToRecords = ( field_label: getChoiceLabel(choice, "default"), field_group_id: element.id, field_group_label: groupLabel, - metadata: { question_type: "ranking", total_items: value.length }, + metadata: { ...baseFields.metadata, question_type: "ranking", total_items: value.length }, value_number: index + 1, }); }); @@ -302,7 +315,7 @@ const expandMultiChoiceToRecords = ( field_label: fieldLabel, field_group_id: element.id, field_group_label: fieldLabel, - metadata: { question_type: "multipleChoiceMulti" }, + metadata: { ...baseFields.metadata, question_type: "multipleChoiceMulti" }, ...valueFields, ...(valueId ? { value_id: valueId } : {}), }); @@ -357,7 +370,7 @@ const normalizeElementValue = ( */ export function transformResponseToFeedbackRecords( response: TResponse, - survey: Pick, + survey: Pick, mappings: TFeedbackSourceFormbricksMapping[], tenantId: string ): FeedbackRecordCreateParams[] {