@reopt-ai/data-sdk-server
v0.5.0
Published
reopt-data analytics SDK for Next.js server components, route handlers, proxy and plain Node
Readme
@reopt-ai/data-sdk-server
reopt-data의 서버 쪽 SDK입니다. Next.js 서버 컴포넌트·라우트 핸들러·proxy.ts·instrumentation.ts, 그리고 Next.js가 아닌 순수 Node 프로세스(워커·크론·스크립트)에서 씁니다.
브라우저에서 도는 코드는 @reopt-ai/data-sdk-client를 쓰세요. 두 패키지를 나눈 이유는 번들 크기가 아니라 경계의 강제입니다 — 이 패키지의 루트는 server-only를 import하므로, 클라이언트 컴포넌트에서 실수로 import하면 런타임이 아니라 빌드가 실패합니다. clientSecret이 브라우저 번들에 실려 나가는 일이 생길 수 없습니다.
설치
pnpm add @reopt-ai/data-sdk-server진입점
| 진입점 | 어디서 쓰나 | 클라이언트 컴포넌트에서 |
| --------------------------------- | ------------------------------------------------- | ----------------------- |
| @reopt-ai/data-sdk-server | 서버 컴포넌트 · 라우트 핸들러 · instrumentation | 빌드 에러 |
| @reopt-ai/data-sdk-server/proxy | proxy.ts (Next 16 — 구 middleware.ts) | 해당 없음 |
| @reopt-ai/data-sdk-server/node | 워커 · 크론 · 스크립트 | 빌드 에러 |
1. 요청 스코프 클라이언트
한 번, 모듈 스코프에서 만듭니다. 엔진과 요청별 리졸버 캐시가 여기에 매달리므로 요청마다 다시 만들면 배칭과 세션 조회 캐시를 모두 잃습니다.
// lib/reopt.ts
import "server-only";
import { createReopt } from "@reopt-ai/data-sdk-server";
import { auth } from "@/auth";
export const { getReopt, getBootstrap } = createReopt({
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
// 쿠키 이름을 유도하는 값 — 브라우저 SDK와 반드시 같아야 합니다
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
baseUrl: process.env.REOPT_BASE_URL!,
getProfileId: async () => (await auth())?.user?.id ?? null,
});// app/api/checkout/route.ts
import { getReopt } from "@/lib/reopt";
export async function POST() {
const reopt = await getReopt();
reopt.track("checkout_started"); // deviceId·profileId 자동 바인딩
}세 개의 식별자가 나오는데 서로 다른 것들입니다:
| 값 | 무엇인가 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| clientId | API client 행의 id입니다 (Client.id, cuid). writeKey가 아닙니다 — 대시보드에서 서버 client를 만들 때 함께 받는 값입니다 |
| clientSecret | 그 행의 서버 비밀값(대시보드 표기: serverSecret). 브라우저 번들에 절대 넣지 마세요 |
| writeKey | 브라우저용 공개 키. 서버에서는 인증이 아니라 쿠키 이름 유도에만 씁니다 |
getReopt()가 하는 일:
cookies()에서 방문자의 device·consent를 읽습니다.- opt-out이면 아무것도 보내지 않는 no-op 클라이언트를 줍니다. 호출부에 동의 분기를 강요하지 않습니다 — 잊어버리는 그 한 곳이 곧 유출입니다.
getProfileId리졸버를 요청당 한 번만 돌립니다(headers()객체를 키로 하는 WeakMap). 결과는 쿠키의 profileId를 덮어씁니다. 쿠키는 브라우저가 쓰고, 세션은 아니기 때문입니다.- 리졸버가 던진 에러 중 Next의 제어흐름(
redirect()·notFound()·동적 렌더 bailout)은 그대로 다시 던집니다. 삼키면 리다이렉트가 빈 페이지가 됩니다. 그 외의 에러는 경고만 남기고 익명으로 폴백합니다 — 분석이 500을 낼 이유는 없습니다. waitUntil을 자동 감지합니다. Vercel 함수는 응답을 반환하는 순간 얼어붙기 때문에, 이게 없으면 fire-and-forgettrack()이 조용히 유실됩니다.
2. Bootstrap — 첫 렌더부터 서버와 브라우저가 같은 사람을 봅니다
// app/layout.tsx (서버 컴포넌트)
import { getBootstrap } from "@/lib/reopt";
import { ReoptProvider, ReoptPageView } from "@reopt-ai/data-sdk-client/next";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const bootstrap = await getBootstrap();
return (
<html>
<body>
<ReoptProvider
config={{ writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!, baseUrl: "/ingest" }}
bootstrap={bootstrap}
>
<ReoptPageView />
{children}
</ReoptProvider>
</body>
</html>
);
}이 패키지는 React 컴포넌트를 내보내지 않습니다. 서버가 클라이언트 컴포넌트를 감싸 주면 경계가 패키지 안으로 숨습니다. 여기서는 RSC → 클라이언트 경계가 여러분의 layout.tsx에 그대로 보입니다.
getBootstrap()은 device 쿠키가 없으면 **null**을 반환합니다(첫 방문 + 시딩 끔, 또는 proxy matcher 밖). 서버가 임의로 id를 만들어 넘기면 브라우저가 자기 것을 또 만들어 영영 수렴하지 않기 때문에, "아무것도 모른다"를 그대로 전달하는 편이 낫습니다.
3. proxy.ts — 쿠키 시딩과 1st-party ingest
// proxy.ts (Next 16에서 middleware.ts를 대체합니다. 기본 런타임은 Node)
import { reoptProxy } from "@reopt-ai/data-sdk-server/proxy";
export const proxy = reoptProxy({
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
baseUrl: process.env.REOPT_BASE_URL!,
proxy: true, // /ingest/* → ${baseUrl}/* (the client sends /ingest/api/track)
});
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };두 가지 일을 합니다:
- device 쿠키 시딩 — 첫 방문에 uuidv7을 심습니다. 렌더 전에 심기 때문에 첫 페이지뷰부터 서버와 브라우저가 같은 id를 씁니다. 동의 전에 쿠키를 쓸 수 없다면
seedDeviceCookie: false. - opt-out 시 쿠키 삭제 — 동의 철회는 "안 쓰기"가 아니라 "지우기"입니다. 남겨 두면 매 요청마다 계속 식별하게 됩니다.
- ingest 프록시 —
/ingest/*를 여러분 도메인에서 받아 rewrite합니다. 클라이언트는${baseUrl}/api/track으로 보내므로baseUrl: "/ingest"이면 실제 요청은/ingest/api/track이고, prefix만 벗겨<reopt-data>/api/track이 됩니다. - 목적지는 두 번 검사합니다.
baseUrl오리진을 벗어나면(//evil.example같은 protocol-relative 경로) 400, 오리진 안이라도/api/아래가 아니면 400입니다.URL이..를 미리 정규화해 없애기 때문에/ingest/../../dashboard는 같은 오리진의/dashboard로 떨어지는데, 대시보드·export·realtime은 각자의 세션으로 직접 접근할 대상이지 고객 도메인을 거쳐 도달할 것이 아닙니다.
다른 proxy 로직과 합성할 때는 응답을 두 번째 인자로 넘깁니다:
const withReopt = reoptProxy({ writeKey, baseUrl, proxy: true });
export function proxy(request: NextRequest) {
const response = NextResponse.next();
response.headers.set("x-custom", "value");
return withReopt(request, response);
}옵션이 아니라 인자인 이유: 옵션은 팩토리 생성 시 한 번 해석되는데 NextResponse는 요청당 하나입니다.
이미 proxy.ts를 쓰고 있는 앱에 얹기
Next는 파일당 proxy 함수를 하나만 허용합니다. 호스트 라우팅이나 인증 rewrite로 proxy.ts가 이미 차 있는 앱이 대부분이므로, reoptProxy는 default export를 가져가지 않고 기존 함수의 마지막 단계로 들어갑니다.
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { reoptProxy } from "@reopt-ai/data-sdk-server/proxy";
import { resolveTenant } from "./lib/tenant";
const withReopt = reoptProxy({
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
baseUrl: process.env.REOPT_BASE_URL!,
proxy: true,
});
export default function proxy(request: NextRequest) {
// 1) 기존 로직이 먼저. rewrite/redirect로 끝나는 요청은 그대로 반환합니다 —
// 응답 본문이 이 앱에서 나오지 않으므로 심을 쿠키도 없습니다.
const tenant = resolveTenant(request);
if (tenant.redirect) return NextResponse.redirect(tenant.redirect);
if (tenant.rewrite) return NextResponse.rewrite(tenant.rewrite);
// 2) 이 앱이 렌더할 요청에만 reopt를 얹습니다. 헤더·쿠키를 이미 얹어 둔
// 응답을 그대로 넘기면 reopt는 device 쿠키만 추가하고 나머지는 보존합니다.
const response = NextResponse.next();
response.headers.set("x-tenant", tenant.id);
return withReopt(request, response);
}마지막에 두는 이유: withReopt가 반환한 응답에 이후 로직이 또 손대면 순서는 지켜지지만, 앞 단계가 redirect/rewrite를 반환했는데 그걸 withReopt에 넘기면 쿠키가 브라우저에 닿지 않는 응답에 붙습니다. "이 앱이 렌더하는 요청"에만 얹는 것이 규칙입니다.
한 배포가 여러 프로젝트를 서빙할 때
호스트마다 다른 브랜드를 서빙하는 앱은 writeKey를 env에서 읽을 수 없습니다 — 어느 프로젝트인지는 요청이 정합니다. writeKey에 함수를 넘기세요.
const withReopt = reoptProxy({
writeKey: (request) => lookupBrandWriteKey(request.headers.get("host")), // string | null | Promise<…>
baseUrl: process.env.REOPT_BASE_URL!,
proxy: true,
});브랜드마다 API client가 다르다면 자격증명도 리졸버로 넘깁니다. 그러면 팩토리는 여전히 하나입니다.
// lib/reopt.ts — 요청 스코프 쪽도 같은 옵션 이름을 씁니다
export const { getReopt, getBootstrap } = createReopt({
// 한 번의 조회로 세 값을 함께 받는 게 보통입니다 — writeKey는 쿠키 이름을,
// clientId/clientSecret은 배치 인증을 담당합니다.
writeKey: async () => (await lookupBrand())?.writeKey ?? null,
credentials: async () => {
const brand = await lookupBrand(); // 같은 요청 안에서는 캐시된 결과
return brand && { clientId: brand.clientId, clientSecret: brand.clientSecret };
},
baseUrl: process.env.REOPT_BASE_URL!,
});credentials가 있으면 clientId/clientSecret보다 우선합니다. 브랜드가 하나뿐이면 예전처럼 문자열 두 개를 넘기면 됩니다 — 둘 다 없으면 경고 한 번을 남기고 서버 이벤트가 꺼집니다.
엔진은 해석된 자격증명 기준으로 캐시됩니다. 팩토리 하나가 여러 프로젝트를 서빙해도 엔진은 요청당 하나가 아니라 프로젝트당 하나입니다. 이 API가 생기기 전에는 앱이 테넌트별로 팩토리를 만들어 모듈 스코프에 직접 메모이즈해야 했고, 그걸 요청마다 새로 만드는 실수가 배칭과 이 캐시를 동시에 날렸습니다.
createReopt의 리졸버는 요청 인자가 없습니다.headers()·cookies()를 직접 읽으면 되고,getProfileId와 같은 WeakMap으로 요청당 한 번만 실행됩니다 — 보통 DB 조회라서, 안 그러면 layout·라우트 핸들러가 페이지당 세 번씩 같은 조회를 합니다.credentials도writeKey·getProfileId와 같은 WeakMap을 씁니다. 셋을 한 조회로 묶어 두면 요청당 DB 왕복은 한 번입니다. 절반만 채워 돌려주면(빈 secret 등) 자격증명이 없는 것으로 취급합니다 — 빈 secret으로 보내면 모든 배치가 인증에 실패하는데, 앱 쪽에서는 네트워크 문제와 구분되지 않기 때문입니다.null을 반환하면 "이 요청은 어느 프로젝트에도 속하지 않는다"는 뜻입니다.getReopt()는 아무것도 보내지 않는 클라이언트를,getBootstrap()은null을 돌려줍니다. 쿠키 이름조차 유도할 수 없으니 읽을 것도 보낼 것도 없습니다.- 리졸버가 던지면 그 요청만 추적되지 않습니다(경고 로그). 단, Next의 동적 렌더 신호는 그대로 다시 던집니다 — 삼키면 한 브랜드의 identity가 박힌 페이지가 모두에게 prerender됩니다.
- proxy에서 리졸버가
null이거나 실패하면 시딩만 생략됩니다./ingest/*rewrite는 그대로 진행합니다 — ingest는 배치의 헤더로 인증하고 rewrite 목적지는baseUrl오리진에 고정돼 있으므로, 여기서 브랜드를 조회하면 사이트가 보내는 모든 이벤트 배치 앞에 DB 조회가 하나씩 붙습니다. - 정적 문자열을 넘기면 proxy 핸들러는 완전히 동기로 돕니다. 리졸버를 쓸 때만
Promise<NextResponse>를 반환하고, Next의 proxy는 둘 다 받습니다.
matcher 주의: 대부분의 앱이 api/를 matcher에서 제외합니다. proxy: true로 /ingest/*를 쓰려면 그 경로가 matcher를 통과해야 합니다 — /ingest는 /api로 시작하지 않으므로 보통 제외 패턴에 걸리지 않지만, matcher를 화이트리스트(["/", "/about", ...])로 쓰는 앱이라면 "/ingest/:path*"를 명시적으로 추가해야 합니다. 빠뜨리면 proxy가 아예 실행되지 않아 브라우저의 /ingest/api/track 요청이 404로 떨어지고, SDK는 조용히 재시도만 반복합니다.
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico).*)",
"/ingest/:path*", // 화이트리스트 matcher를 쓴다면 이 줄이 필수입니다
],
};4. 서버 에러를 $exception으로
// instrumentation.ts
import { createOnRequestError } from "@reopt-ai/data-sdk-server";
export const onRequestError = createOnRequestError({
writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
baseUrl: process.env.REOPT_BASE_URL!,
});nextjs_route_path / nextjs_router_kind / nextjs_route_type / nextjs_render_source / nextjs_revalidate_reason / nextjs_error_digest를 붙입니다. 에러 추적 도구와 겹치지만, 제품 가치는 에러율을 퍼널·세그먼트와 같은 축에서 볼 수 있다는 데 있습니다.
브라우저 SDK와 같은 $exception_list(구조화된 cause 체인)도 함께 실립니다 —
mechanism.type은 nextjs_onRequestError, handled는 false입니다. 프레임의
in_app은 node_modules/와 node: 내장 모듈을 제외한 것이라, 스택 패널이
기본으로 여는 줄이 실제로 고칠 수 있는 코드입니다. 파서는 브라우저 SDK와 같은
구현을 씁니다 — 같은 모양의 스택이 서버와 클라이언트에서 다른 이슈로 묶이면
안 되니까요. 필드 표와 예시 JSON은 브라우저 SDK README에
있습니다.
beforeCapture로 속성을 병합하거나({...} 반환) 캡처를 취소할 수 있습니다(false 반환). 훅이 던지면 캡처를 건너뜁니다 — 훅이 무엇을 가려야 했는지 모르는 채 보내는 것보다 안 보내는 편이 안전합니다.
한 배포가 여러 프로젝트를 서빙할 때
브랜드마다 커스텀 도메인을 쓰는 프론트엔드는 요청마다 자격이 다릅니다. resolve로
해석하세요:
// instrumentation.ts
export const onRequestError = createOnRequestError({
baseUrl: process.env.REOPT_BASE_URL!,
resolve: async (request) => {
const host = readHeader(request.headers, "host");
const brand = host ? await findBrandByDomain(host) : null;
if (!brand) return null; // 이 요청은 어느 프로젝트의 것도 아님
return {
writeKey: brand.reoptWriteKey,
clientId: brand.reoptClientId,
clientSecret: brand.reoptClientSecret,
};
},
});writeKey도 함께 돌려줘야 합니다. 자격만으로는 배치 인증만 되고 귀속이 안
됩니다 — 방문자의 신원이 담긴 쿠키 이름을 write key가 정하기 때문에, 브랜드 A의
쿠키를 B의 키로 읽으면 아무것도 못 찾습니다.
null반환 → 캡처하지 않습니다. 브랜드 외 요청(마케팅 페이지, 헬스체크)이 정상적으로 여기 해당합니다.- 던지면 → 역시 캡처하지 않습니다.
beforeCapture와 같은 읽기입니다: 답하지 못한 질문은 승낙이 아니고, 테넌트를 찍어서 엉뚱한 프로젝트에 이슈를 만드는 것이 안 만드는 것보다 나쁩니다. resolve가 있으면 정적 옵션보다 우선합니다. 없으면 정적 옵션이 그대로 쓰입니다.
캐시하지 않습니다. createReopt의 resolver는 한 페이지가 클라이언트를 여러 번
요청할 수 있어 요청당 1회로 캐시하지만, 이건 에러당 1회라 이미 드물고, 캐시를
걸면 그 캐시를 정당화한 요청보다 오래 삽니다.
4-1. 소스맵 업로드 — @reopt-ai/data-cli
프로덕션 스택은 minify된 청크 이름만 말합니다. 소스맵을 올려 두면 서버가 그것을 원본
위치로 되돌립니다. 업로드는 이 패키지가 아니라 별도 공개 CLI
@reopt-ai/data-cli(reopt-data)가
담당합니다 — 빌드 파이프라인 도구를 런타임 SDK에 실어 보낼 이유가 없어서입니다.
pnpm add -D @reopt-ai/data-cli
reopt-data sourcemap inject --dir .next/static # 선택: URL이 바뀌어도 맵이 해석되게
reopt-data sourcemap upload --dir .next/static \
--url-prefix https://app.example.com/_next/static \
--project-id <id> --release "$VERCEL_GIT_COMMIT_SHA" --delete-after-upload한 배포가 여러 프로젝트를 서빙하면(symbolHostApp, 위 3·4절) 호스트 앱 단위로
경로 기준 한 번만 올립니다:
reopt-data sourcemap upload --dir .next/static --path-prefix /_next/static \
--host-app <host app> --organization-id <org id> --release "$VERCEL_GIT_COMMIT_SHA"타깃 세 가지(프로젝트 / 호스트 앱 / 플랫폼)·자격 증명·--dry-run·종료 코드는 그 패키지의
README가 canonical입니다. 이 패키지의 예전 reopt-data bin(upload-sourcemaps /
inject-chunk-ids)은 0.5.0에서 제거됐고, 같은 철자가 새 CLI에서 그대로 동작합니다.
5. 방문자의 device id만 필요할 때
서버에서 확정한 전환을 나중에 보내는 앱 — 아웃박스에 적어 두고 크론이 배치로 포워딩하는 구조 — 은 요청 시점에 "이 전환이 누구 것인지"만 기록하면 됩니다.
import { readDeviceId } from "@reopt-ai/data-sdk-server";
import { cookies } from "next/headers";
const deviceId = readDeviceId(await cookies(), { writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY! });
await db.analyticsOutbox.create({ data: { name: "form.submitted", deviceId, ... } });나중에 포워더가 그 값을 이벤트에 실어 보내면(deviceId 필드, 계약 0.6+) ingest가 행 단위로 그 방문자의 활성 세션에 붙입니다. 한 배치에 여러 방문자가 섞여도 됩니다 — 배치를 쪼갤 필요가 없으므로 accepted + duplicates + rejected === sent 불변식이 유지됩니다.
cookies() 결과, NextRequest.cookies, raw Cookie: 헤더 문자열을 모두 받습니다. 읽을 게 없으면 null을 반환하고 throw하지 않습니다 — 분석 때문에 결제가 실패하면 안 되니까요. 쿠키는 형제 서브도메인이 심을 수 있는 입력이므로 값은 검증을 거쳐 나옵니다.
쿠키 이름을 직접 조립하지 마세요. 이름은 writeKey에서 유도되며, 그 규칙이 두 저장소에 각각 적히면 조용히 어긋납니다 — 한쪽이 쓰고 다른 쪽이 못 찾아서 모든 방문자가 신규로 보입니다.
6. 순수 Node
import { createReoptNode } from "@reopt-ai/data-sdk-server/node";
const reopt = createReoptNode({
clientId: process.env.REOPT_CLIENT_ID!,
clientSecret: process.env.REOPT_CLIENT_SECRET!,
baseUrl: process.env.REOPT_BASE_URL!,
});
await reopt.trackAndFlush("nightly_report_generated", { rows: 12_400 });
await reopt.close(); // 스크립트 종료 전 필수 — 타이머를 멈추고 큐를 비웁니다Next.js 앱 안에서는 createReopt()를 쓰세요. 이 클라이언트는 읽을 요청이 없어서 이벤트를 방문자에게 묶지 못합니다.
세션은 어떻게 이어지나
서버 이벤트가 방문자의 device를 들고 오면 — 요청 헤더(getReopt()가 쿠키에서 읽어 자동으로 붙입니다)든, 이벤트 자신의 deviceId 필드(포워더)든 — ingest가 그 device의 활성 세션에 합류시킵니다. 활성 세션이 없으면(몇 시간 뒤 도착한 웹훅 등) 세션 없이 적재하고 새로 만들지 않습니다 — pageview 0짜리 유령 세션이 이탈률을 오염시키기 때문입니다.
행 단위 deviceId가 요청 헤더보다 우선합니다. 브라우저 자격증명(writeKey)으로 보낸 요청에서는 이 필드가 무시됩니다 — 거기선 헤더가 SDK 자신의 쿠키 값이고 body는 페이지가 쓴 것이라, body를 믿으면 아무 페이지나 원하는 device로 이벤트를 귀속시킬 수 있습니다.
브라우저가 보낸 reopt-session-id는 신뢰하지 않습니다. 누구나 임의 값을 넣을 수 있어, 그대로 믿으면 남의 세션에 이벤트를 주입할 수 있습니다. 세션은 항상 device에서 유도합니다.
개발
pnpm --filter @reopt-ai/data-sdk-server test # 단위 (tsx --test)
pnpm test:e2e:sdk # 격리된 전체 pipeline (vitest)
pnpm --filter @reopt-ai/data-sdk-server build # tsdown + dist 검증build는 scripts/check-dist-guards.mjs로 산출물을 검사합니다: 서버 진입점이 server-only를 실제로 import하는지, dist 어디에도 DOM 참조가 없는지. 소스에서는 성립하다가 번들에서 조용히 깨지는 종류의 성질이라 빌드에 묶어 뒀습니다.
