From eaec6e93d38eb79f43b4d8ac66b4d59a3dacfb34 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 11 Sep 2026 14:11:50 +0200 Subject: [PATCH] test(e2e): Add node-prisma-8 test app Express app on Prisma 8 rc.8 with Postgres 16 in Docker, asserting the `prisma:client:operation` spans and the `pg` spans nested under them through the runtime hook. Refs #24237 Co-Authored-By: Claude Fable 5.1 --- .../node-prisma-8/.gitignore | 1 + .../node-prisma-8/docker-compose.yml | 21 + .../node-prisma-8/global-setup.mjs | 14 + .../node-prisma-8/global-teardown.mjs | 12 + .../test-applications/node-prisma-8/init.sql | 64 +++ .../node-prisma-8/package.json | 30 ++ .../node-prisma-8/playwright.config.mjs | 11 + .../node-prisma-8/src/app.ts | 30 ++ .../node-prisma-8/src/instrument.ts | 9 + .../node-prisma-8/src/prisma/contract.d.ts | 390 ++++++++++++++++++ .../node-prisma-8/src/prisma/contract.json | 132 ++++++ .../node-prisma-8/src/prisma/contract.prisma | 9 + .../node-prisma-8/start-event-proxy.mjs | 6 + .../node-prisma-8/tests/prisma.test.ts | 78 ++++ .../node-prisma-8/tsconfig.json | 14 + 15 files changed, 821 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.prisma create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-prisma-8/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/.gitignore b/dev-packages/e2e-tests/test-applications/node-prisma-8/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/docker-compose.yml b/dev-packages/e2e-tests/test-applications/node-prisma-8/docker-compose.yml new file mode 100644 index 000000000000..ad294fe8d2ea --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/docker-compose.yml @@ -0,0 +1,21 @@ +services: + db: + # Prisma 8 requires PostgreSQL 15 or newer. + image: postgres:16 + restart: always + container_name: e2e-tests-node-prisma-8 + ports: + - '5438:5432' + environment: + POSTGRES_USER: prisma + POSTGRES_PASSWORD: prisma + POSTGRES_DB: tests + # Dumped from `prisma-cli db init`; the Prisma 8 CLI needs Node 22.18+, so it isn't run at test time. + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U prisma -d tests'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/global-setup.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-setup.mjs new file mode 100644 index 000000000000..53682b876046 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start PostgreSQL via Docker Compose. `--wait` blocks until the healthcheck + // in docker-compose.yml passes, so the app can connect immediately. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql b/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql new file mode 100644 index 000000000000..655d447ca485 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql @@ -0,0 +1,64 @@ +CREATE SCHEMA prisma_contract; +CREATE TABLE prisma_contract.contract ( + core_hash text NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + contract_json jsonb NOT NULL +); +CREATE TABLE prisma_contract.ledger ( + id bigint NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + space text NOT NULL, + migration_name text NOT NULL, + migration_hash text NOT NULL, + origin_core_hash text, + origin_profile_hash text, + destination_core_hash text NOT NULL, + destination_profile_hash text, + operations jsonb NOT NULL +); +CREATE SEQUENCE prisma_contract.ledger_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE prisma_contract.ledger_id_seq OWNED BY prisma_contract.ledger.id; +CREATE TABLE prisma_contract.marker ( + space text DEFAULT 'app'::text NOT NULL, + core_hash text NOT NULL, + profile_hash text NOT NULL, + contract_json jsonb, + canonical_version integer, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + app_tag text, + meta jsonb DEFAULT '{}'::jsonb NOT NULL, + invariants text[] DEFAULT '{}'::text[] NOT NULL +); +CREATE TABLE public."user" ( + email text NOT NULL, + id integer NOT NULL, + name text +); +CREATE SEQUENCE public.user_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; +ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; +ALTER TABLE ONLY prisma_contract.ledger ALTER COLUMN id SET DEFAULT nextval('prisma_contract.ledger_id_seq'::regclass); +ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); +INSERT INTO prisma_contract.contract VALUES ('f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '2026-09-09 09:15:28.369559+00', '{"meta": {}, "roots": {"user": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": false}}, "storage": {"table": "user", "fields": {"id": {"column": "id"}, "name": {"column": "name"}, "email": {"column": "email"}}, "namespaceId": "public"}, "relations": {}}}}}}, "target": "postgres", "storage": {"namespaces": {"public": {"id": "public", "entries": {"table": {"user": {"columns": {"id": {"codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "nullable": false, "nativeType": "int4"}, "name": {"codecId": "pg/text@1", "nullable": true, "nativeType": "text"}, "email": {"codecId": "pg/text@1", "nullable": false, "nativeType": "text"}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lateral": true, "returning": true, "scalarList": true, "checkConstraint": true, "defaultInInsert": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true}}, "targetFamily": "sql"}'); +INSERT INTO prisma_contract.ledger VALUES (1, '2026-09-09 09:15:28.369559+00', 'app', '', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '', NULL, 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', NULL, '[{"id": "table.user", "label": "Create table \"user\"", "target": {"id": "postgres", "details": {"name": "user", "schema": "public", "objectType": "table"}}, "execute": [{"sql": "CREATE TABLE \"public\".\"user\" (\n \"email\" text NOT NULL,\n \"id\" SERIAL NOT NULL,\n \"name\" text,\n PRIMARY KEY (\"id\")\n)", "params": [], "description": "create table \"user\""}], "summary": "Creates table \"user\"", "precheck": [{"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "ensure table \"user\" does not exist"}], "postcheck": [{"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "verify table \"user\" exists"}], "operationClass": "additive"}, {"id": "unique.user.user_email_key", "label": "Add unique constraint on \"user\" (email)", "target": {"id": "postgres", "details": {"name": "user_email_key", "table": "user", "schema": "public", "objectType": "unique"}}, "execute": [{"sql": "ALTER TABLE \"public\".\"user\" ADD CONSTRAINT \"user_email_key\" UNIQUE (\"email\")", "description": "add unique constraint \"user_email_key\""}], "precheck": [{"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "ensure constraint \"user_email_key\" does not exist"}], "postcheck": [{"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "verify constraint \"user_email_key\" exists"}], "operationClass": "additive"}]'); +INSERT INTO prisma_contract.marker VALUES ('app', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-09-09 09:15:28.369559+00', NULL, '{}', '{}'); +ALTER TABLE ONLY prisma_contract.contract + ADD CONSTRAINT contract_pkey PRIMARY KEY (core_hash); +ALTER TABLE ONLY prisma_contract.ledger + ADD CONSTRAINT ledger_pkey PRIMARY KEY (id); +ALTER TABLE ONLY prisma_contract.marker + ADD CONSTRAINT marker_pkey PRIMARY KEY (space); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_email_key UNIQUE (email); +ALTER TABLE ONLY public."user" + ADD CONSTRAINT user_pkey PRIMARY KEY (id); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json new file mode 100644 index 000000000000..a6e8bb692e57 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json @@ -0,0 +1,30 @@ +{ + "name": "node-prisma-8", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node --import ./dist/instrument.js dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@prisma/orm-postgres": "8.0.0-rc.8", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@types/express": "^4.17.21", + "@types/node": "^20.19.0", + "express": "^4.21.2", + "typescript": "~5.9.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/playwright.config.mjs new file mode 100644 index 000000000000..d5fd0b394f15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/playwright.config.mjs @@ -0,0 +1,11 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/app.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/app.ts new file mode 100644 index 000000000000..2b90f41a9edf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/app.ts @@ -0,0 +1,30 @@ +import { randomBytes } from 'node:crypto'; +import postgres from '@prisma/orm-postgres/runtime'; +import express from 'express'; +import type { Contract } from './prisma/contract.d.ts'; +import contractJson from './prisma/contract.json' with { type: 'json' }; + +const db = postgres({ + contractJson, + url: 'postgresql://prisma:prisma@localhost:5438/tests', +}); + +const app = express(); +const port = 3030; + +app.get('/test-prisma', async (_req, res) => { + const created = await db.orm.public.User.create({ + name: 'Tilda', + email: `tilda_${randomBytes(4).toString('hex')}@sentry.io`, + }); + + const users = await db.orm.public.User.all(); + + await db.orm.public.User.where(user => user.email.like('%sentry.io')).delete(); + + res.json({ created: created.id, count: users.length }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/instrument.ts new file mode 100644 index 000000000000..f3dd95215d03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/instrument.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts new file mode 100644 index 000000000000..010a73846882 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts @@ -0,0 +1,390 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma contract emit'. +// To regenerate, run: prisma contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma/orm-postgres/adapter/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + TimeString, + Timestamp, + TimestampString, + Timestamptz, + TimestamptzString, + Timetz, + VarBit, + Varchar, +} from '@prisma/orm-postgres/target/codec-types'; + +import type { ContractWithTypeMaps, TypeMaps as TypeMapsType } from '@prisma/orm-postgres/family-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma/orm-postgres/contract/types'; + +export type StorageHash = StorageHashBase<'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +export type AggregateTypes = { + readonly avg: { + readonly byCodec: { + readonly 'pg/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'sql/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + }; + }; + readonly avgDecimal: { + readonly byCodec: { + readonly 'pg/int@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + }; + }; + readonly count: { + readonly byCodec: {}; + readonly withoutInput: { readonly output: 'pg/int8number@1'; readonly nullable: false }; + readonly anyInput: { readonly output: 'pg/int8number@1'; readonly nullable: false }; + }; + readonly countBigInt: { + readonly byCodec: {}; + readonly withoutInput: { readonly output: 'pg/int8@1'; readonly nullable: false }; + readonly anyInput: { readonly output: 'pg/int8@1'; readonly nullable: false }; + }; + readonly max: { + readonly byCodec: { + readonly 'pg/char@1': { readonly output: 'pg/char@1'; readonly nullable: true }; + readonly 'pg/date-string@1': { readonly output: 'pg/date-string@1'; readonly nullable: true }; + readonly 'pg/date-temporal@1': { + readonly output: 'pg/date-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/enum@1': { readonly output: 'pg/enum@1'; readonly nullable: true }; + readonly 'pg/float@1': { readonly output: 'pg/float@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float4@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/inet@1': { readonly output: 'pg/inet@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/int@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int2@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int4@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/text-array@1': { readonly output: 'pg/text-array@1'; readonly nullable: true }; + readonly 'pg/text@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/time-string@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { + readonly output: 'pg/time-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-string@1': { + readonly output: 'pg/timestamp-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-temporal@1': { + readonly output: 'pg/timestamp-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-string@1': { + readonly output: 'pg/timestamptz-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-temporal@1': { + readonly output: 'pg/timestamptz-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timetz@1': { readonly output: 'pg/timetz@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'pg/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'sql/char@1': { readonly output: 'sql/char@1'; readonly nullable: true }; + readonly 'sql/float@1': { readonly output: 'sql/float@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'sql/int@1'; readonly nullable: true }; + readonly 'sql/text@1': { readonly output: 'sql/text@1'; readonly nullable: true }; + readonly 'sql/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + }; + }; + readonly min: { + readonly byCodec: { + readonly 'pg/char@1': { readonly output: 'pg/char@1'; readonly nullable: true }; + readonly 'pg/date-string@1': { readonly output: 'pg/date-string@1'; readonly nullable: true }; + readonly 'pg/date-temporal@1': { + readonly output: 'pg/date-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/enum@1': { readonly output: 'pg/enum@1'; readonly nullable: true }; + readonly 'pg/float@1': { readonly output: 'pg/float@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float4@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/inet@1': { readonly output: 'pg/inet@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/int@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int2@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int4@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/text-array@1': { readonly output: 'pg/text-array@1'; readonly nullable: true }; + readonly 'pg/text@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/time-string@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { + readonly output: 'pg/time-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-string@1': { + readonly output: 'pg/timestamp-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamp-temporal@1': { + readonly output: 'pg/timestamp-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-string@1': { + readonly output: 'pg/timestamptz-string@1'; + readonly nullable: true; + }; + readonly 'pg/timestamptz-temporal@1': { + readonly output: 'pg/timestamptz-temporal@1'; + readonly nullable: true; + }; + readonly 'pg/timetz@1': { readonly output: 'pg/timetz@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'pg/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + readonly 'sql/char@1': { readonly output: 'sql/char@1'; readonly nullable: true }; + readonly 'sql/float@1': { readonly output: 'sql/float@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'sql/int@1'; readonly nullable: true }; + readonly 'sql/text@1': { readonly output: 'sql/text@1'; readonly nullable: true }; + readonly 'sql/varchar@1': { readonly output: 'pg/text@1'; readonly nullable: true }; + }; + }; + readonly sum: { + readonly byCodec: { + readonly 'pg/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/float4@1': { readonly output: 'pg/float4@1'; readonly nullable: true }; + readonly 'pg/float8@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'pg/int@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + readonly 'pg/interval@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/numeric@1': { readonly output: 'pg/numeric@1'; readonly nullable: true }; + readonly 'pg/time-string@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/time-temporal@1': { readonly output: 'pg/interval@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'sql/float@1': { readonly output: 'pg/float8@1'; readonly nullable: true }; + readonly 'sql/int@1': { readonly output: 'pg/int8number@1'; readonly nullable: true }; + }; + }; + readonly sumBigInt: { + readonly byCodec: { + readonly 'pg/int@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int2@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int4@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + readonly 'pg/int8@1': { readonly output: 'pg/unboundedint@1'; readonly nullable: true }; + readonly 'pg/int8number@1': { readonly output: 'pg/unboundedint@1'; readonly nullable: true }; + readonly 'pg/unboundedint@1': { + readonly output: 'pg/unboundedint@1'; + readonly nullable: true; + }; + readonly 'sql/int@1': { readonly output: 'pg/int8@1'; readonly nullable: true }; + }; + }; +}; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? Encoded extends CodecTypes[CodecId]['json'] + ? Encoded + : CodecTypes[CodecId]['json'] + : Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly email: CodecTypes['pg/text@1']['output']; + readonly name: CodecTypes['pg/text@1']['output'] | null; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly User: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly email: CodecTypes['pg/text@1']['input']; + readonly name: CodecTypes['pg/text@1']['input'] | null; + }; + }; +}; +export type StorageColumnTypes = { + readonly public: { + readonly user: { + readonly email: CodecTypes['pg/text@1']['output']; + readonly id: CodecTypes['pg/int4@1']['output']; + readonly name: CodecTypes['pg/text@1']['output'] | null; + }; + }; +}; +export type StorageColumnInputTypes = { + readonly public: { + readonly user: { + readonly email: CodecTypes['pg/text@1']['input']; + readonly id: CodecTypes['pg/int4@1']['input']; + readonly name: CodecTypes['pg/text@1']['input'] | null; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes, + StorageColumnTypes, + StorageColumnInputTypes, + AggregateTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly user: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + readonly default: { + readonly kind: 'function'; + readonly expression: 'autoincrement()'; + }; + }; + readonly email: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: true; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly [{ readonly columns: readonly ['email'] }]; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly user: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly email: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly name: { + readonly nullable: true; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'user'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly email: { readonly column: 'email' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly checkConstraint: true; + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + readonly scalarList: true; + }; + }; + readonly extensions: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json new file mode 100644 index 000000000000..3b9090e28776 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json @@ -0,0 +1,132 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", + "roots": { + "user": { + "model": "User", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "User": { + "fields": { + "email": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "name": { + "nullable": true, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "email": { + "column": "email" + }, + "id": { + "column": "id" + }, + "name": { + "column": "name" + } + }, + "namespaceId": "public", + "table": "user" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "user": { + "columns": { + "email": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/int4@1", + "default": { + "expression": "autoincrement()", + "kind": "function" + }, + "nativeType": "int4", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [ + { + "columns": ["email"] + } + ] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "checkConstraint": true, + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true, + "scalarList": true + } + }, + "extensions": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma contract emit\".", + "regenerate": "To regenerate, run: prisma contract emit" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.prisma b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.prisma new file mode 100644 index 000000000000..e443ac76a64e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.prisma @@ -0,0 +1,9 @@ +// use prisma-next + +// Regenerate `contract.json`/`contract.d.ts` with `prisma-cli contract emit` and `../../init.sql` from +// `prisma-cli db init` (Node 22.18+). No `DateTime` column: Prisma 8 decodes timestamps into `Temporal`. +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? +} diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-prisma-8/start-event-proxy.mjs new file mode 100644 index 000000000000..8095fa121543 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-prisma-8', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts new file mode 100644 index 000000000000..81cc629a2ff8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; + +const OPERATION_METHODS = ['create', 'all', 'delete']; + +function isPrismaOperation(span: SerializedStreamedSpan): boolean { + return span.attributes['sentry.origin']?.value === 'auto.db.prisma'; +} + +function isTestPrismaSegment(span: SerializedStreamedSpan): boolean { + return getSpanOp(span) === 'http.server' && span.is_segment === true && span.name === 'GET /test-prisma'; +} + +test('Prisma 8 ORM calls emit operation spans with the pg queries nested underneath', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + 'node-prisma-8', + spans => spans.some(isTestPrismaSegment) && spans.filter(isPrismaOperation).length >= OPERATION_METHODS.length, + ); + + const res = await fetch(`${baseURL}/test-prisma`); + expect(res.status).toBe(200); + + const spans = await spansPromise; + const segment = spans.find(isTestPrismaSegment)!; + const operationSpans = spans.filter(isPrismaOperation); + const spansById = new Map(spans.map(span => [span.span_id, span])); + // The Express request-handler span sits between the segment and the ORM calls. + const isInSegment = (span: SerializedStreamedSpan): boolean => { + for (let parent = span.parent_span_id; parent; parent = spansById.get(parent)?.parent_span_id) { + if (parent === segment.span_id) { + return true; + } + } + return false; + }; + + expect(operationSpans.map(span => span.attributes['method']?.value)).toEqual(OPERATION_METHODS); + operationSpans.forEach(span => { + expect(span.name).toBe('prisma:client:operation'); + expect(isInSegment(span)).toBe(true); + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.db.prisma', type: 'string' }, + 'sentry.op': { value: 'db', type: 'string' }, + 'db.operation.name': { value: span.attributes['method']?.value, type: 'string' }, + 'db.collection.name': { value: 'user', type: 'string' }, + model: { value: 'User', type: 'string' }, + name: { value: `User.${span.attributes['method']?.value}`, type: 'string' }, + }); + }); + + const queriesUnder = (method: string): unknown[] => { + const operation = operationSpans.find(span => span.attributes['method']?.value === method)!; + // `pg.connect` spans are `db` spans too, but carry no statement. + return spans + .filter( + span => + getSpanOp(span) === 'db' && + span.attributes['db.query.text']?.value && + span.parent_span_id === operation.span_id, + ) + .map(span => span.attributes['db.query.text']?.value); + }; + expect(queriesUnder('create')).toEqual( + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + ); + expect(queriesUnder('all')).toEqual([expect.stringMatching(/^SELECT .* FROM "public"\."user"$/)]); + expect(queriesUnder('delete')).toEqual( + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + ); + + const dbSpans = spans.filter(span => getSpanOp(span) === 'db' && span.attributes['db.query.text']?.value); + dbSpans.forEach(span => { + expect(span.attributes['sentry.origin']?.value).toBe('auto.db.postgres'); + expect(span.attributes['db.system.name']?.value).toBe('postgresql'); + expect(span.parent_span_id).not.toBe(segment.span_id); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/tsconfig.json new file mode 100644 index 000000000000..6dd09736082f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +}