리옵트 데이터
워크스페이스

추적 설정

프로젝트를 만든 뒤 브라우저, React, 서버리스 환경에 이벤트 수집을 붙이는 방법을 안내합니다.

프로젝트를 만들었다면 다음 단계는 이벤트 수집을 붙이는 것입니다. 처음에는 이벤트를 많이 보내는 것보다 키를 올바른 환경에 배포하고 로그인 전후 사용자를 안정적으로 연결하는 것이 더 중요합니다.

처음 연동한다면 빠른 연동을 먼저 따라 해보세요. 이 문서는 SDK 동작, HTTP 직접 호출 형식, 검증 기준을 더 자세히 다룹니다.

먼저 지켜야 할 원칙

  1. 공개 환경에는 writeKey만 배포합니다.
  2. 서버 환경에는 clientId와 serverSecret만 배포합니다.
  3. 로그인 직후에는 identify로 익명 행동과 사용자 프로필을 연결합니다.
  4. 서버 이벤트는 응답 뒤에도 전송이 끝나게 합니다. Next.js 요청 스코프 클라이언트는 SDK가 처리하고, 워커·크론·스크립트는 trackAndFlush 계열과 close()로 직접 비웁니다.

제품 화면에서는 서버 비밀 키를 serverSecret으로 보여줍니다. SDK 설정과 환경변수 예시에서는 같은 값을 clientSecret이라는 이름으로 넣습니다.

시작하기 전에 준비할 것

준비물설명
writeKey브라우저와 client component에서 사용하는 공개 수집 키입니다.
clientId서버용 client의 ID입니다. writeKey와 다른 값입니다.
serverSecret서버 SDK 인증에 쓰는 비밀 키입니다.
baseUrl리옵트 데이터의 주소입니다. 기본값이 없으므로 https://data.reopt.ai를 명시합니다.
테스트 이벤트 이름예: signup_clicked, checkout_completed

키를 확인한 뒤에는 브라우저용과 서버용 환경변수를 나눠 넣으세요. 공개 prefix가 붙은 값은 브라우저에 노출될 수 있다고 보고 관리하세요. 브라우저 SDK는 writeKey나 baseUrl이 비어 있으면 콘솔에 경고를 남기고 아무것도 보내지 않습니다. 페이지를 실패시키지는 않습니다.

핵심 객체

객체역할
Client프로젝트의 추적 주체를 식별하는 API client
writeKey브라우저 또는 공개 환경에서 쓰는 수집 키
clientId서버 SDK가 사용하는 서버용 client의 ID
serverSecret서버 환경 전용 비밀 키
deviceId로그인 전 사용자를 묶는 익명 디바이스 ID
profileId로그인 후 식별된 사용자 ID
eventId클라이언트가 생성하는 UUID. 재전송 중복을 걸러내는 기준입니다.
timestamp클라이언트가 이벤트를 만든 millisecond Unix timestamp

serverSecret은 브라우저 번들, 공개 저장소, client component, HTML snippet에 들어가면 안 됩니다. 노출되었다면 reopt-data project rotate-secret으로 새 secret을 발급하세요. 기존 값은 즉시 동작을 멈춥니다.

수집 진입점

  • API route: POST /api/track
  • CORS preflight: OPTIONS /api/track

SDK는 ${baseUrl}/api/track로 이벤트를 보냅니다.

HTTP로 직접 호출한다면 브라우저 키와 서버 키의 헤더를 구분하세요.

환경필요한 헤더
브라우저 또는 공개 환경reopt-write-key
서버 환경reopt-client-id, reopt-client-secret
익명 사용자 연결 보강reopt-device-id

수집 모드는 어떤 키를 보냈는지에 따라 정해집니다. 모드를 따로 지정하는 헤더는 없습니다.

모드자격증명동작
browserreopt-write-key같은 device의 같은 내용 이벤트를 5초 안에 다시 보내면 하나로 합칩니다. 잘못된 이벤트가 하나라도 있으면 배치 전체가 400입니다.
serverreopt-client-id + reopt-client-secret5초 중복 제거가 없습니다. 잘못된 이벤트는 응답의 rejected에 행별로 보고하고 나머지는 처리합니다.

요청 한도는 다음과 같습니다.

항목한도
요청 본문512,000 bytes. 넘으면 400 payload_too_large입니다.
요청 빈도프로젝트당 분당 1,000건. 넘으면 429 rate_limited와 Retry-After를 받습니다.
이벤트 이름1~200자
profileId문자열이면 500자 이하

도입 순서

  1. 통합 프로젝트 화면(/dashboard)에서 조직을 선택해 프로젝트를 생성합니다.
  2. 브라우저용 client와 서버용 client가 따로 발급됩니다.
  3. 브라우저/React/Next.js client component에는 browser client의 writeKey를 넣습니다.
  4. 서버 route handler, server action, worker, cron job에는 server client의 clientId + serverSecret을 넣습니다.
  5. 프로젝트 홈의 SDK snippet으로 첫 이벤트를 붙입니다.
  6. 이벤트 스트림 또는 데이터 카탈로그에서 이벤트 이름, 속성, 프로필 연결을 확인합니다.
  7. 카탈로그에서 전환, 태그, 카테고리, 상태를 정리합니다.

브라우저에 붙이기

import { init, identify, track } from "@reopt-ai/data-sdk-client";

init({
  writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_REOPT_API_URL!,
});

track("signup_clicked", {
  source: "landing",
  plan: "starter",
});

identify("user_123", {
  plan: "starter",
});

init()은 writeKey마다 클라이언트를 하나만 만들고, 생성할 때 첫 $pageview를 직접 보냅니다. 같은 writeKey로 다시 호출하면 이미 만든 클라이언트를 돌려주므로 두 번 초기화해도 device나 큐가 둘이 되지 않습니다. SPA에서 화면이 바뀔 때는 pageView()를 호출하세요. history를 가로채는 자동 감지는 없습니다. 첫 페이지뷰를 직접 보내고 싶다면 capture: { pageview: false }로 끕니다.

브라우저 SDK는 이벤트를 바로 네트워크로 보내지 않고 내부 큐에 넣습니다. 기본값으로는 이벤트가 100개 모이거나 1초가 지나면 flush하고, 한 요청이 400,000 bytes를 넘지 않게 나눕니다. 페이지가 숨겨지거나 언로드될 때는 keepalive 전송을 시도합니다. 이 방식으로 페이지 이동 중의 이벤트 손실을 줄입니다.

track(), identify() 같은 호출은 큐잉 결과를 바로 돌려줍니다. queued가 false이면 reason에 이유가 들어 있습니다. 값은 tracking_paused, consent_denied, validation_failed, payload_too_large 중 하나입니다. init() 전에 부른 호출은 콘솔에 경고를 남기고 undefined를 돌려줍니다.

자주 쓰는 옵션

옵션기본값설명
capture.pageviewtrue생성 시 첫 $pageview를 보냅니다. Next.js 진입점은 ReoptPageView가 맡으므로 false로 둡니다.
capture.pageleavetrue페이지를 떠나거나 숨길 때 체류 시간이 담긴 $pageleave를 보냅니다.
capture.scrollDepthtrue$pageleave에 최대 스크롤 깊이를 붙입니다.
capture.exceptionsfalse잡히지 않은 에러와 unhandled rejection을 $exception으로 보냅니다.
release없음track 이벤트(자동 이벤트 포함)에 $release_id로 붙는 빌드 식별자입니다. 없으면 번들러 define이 주입한 __REOPT_RELEASE__를 읽습니다.
properties없음첫 이벤트부터 모든 track 이벤트(자동 이벤트 포함)에 붙는 속성입니다. 이후에는 register()로 갱신합니다.
normalizePath없음SDK가 스스로 붙이는 경로를 바꿉니다. /orders/123 같은 경로를 /orders/:id로 접을 때 씁니다.
consentanalytics만, 기본 허용, 저장함categories, defaultConsent, persist로 조정합니다.
identity.storageautodevice ID 저장 위치. 쿠키, localStorage, 메모리 순으로 시도합니다.
identity.cookieDomain호스트 전용.example.com처럼 지정하면 서브도메인끼리 같은 방문자로 봅니다. reoptProxy를 쓴다면 같은 값을 cookieDomain으로 넘기세요.
batchsize 100, intervalMs 1000한 요청의 이벤트 수와 대기 시간. maxBytes 기본값은 400,000입니다.
sessionReplay꺼짐아래 세션 리플레이 켜기를 확인하세요.
debugfalse큐잉과 전송 과정을 콘솔에 남깁니다.

normalizePath는 pageView()에 직접 넘긴 path에는 적용되지 않습니다. 같은 함수를 호출부에서도 쓰세요.

React Provider

"use client";

import { ReoptProvider, useTrack } from "@reopt-ai/data-sdk-client/react";

export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
  return (
    <ReoptProvider
      config={{
        writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
        baseUrl: process.env.NEXT_PUBLIC_REOPT_API_URL!,
      }}
    >
      {children}
    </ReoptProvider>
  );
}

function SignupButton() {
  const track = useTrack();

  return <button onClick={() => track("signup_clicked", { location: "header" })}>Sign up</button>;
}

Provider는 클라이언트를 렌더 중에 만듭니다. React는 effect를 자식부터 실행하므로, effect에서 만들면 동의 배너나 useTrackOnMount 같은 자식이 클라이언트가 생기기 전에 실행되기 때문입니다. 생성은 writeKey 단위로 멱등이라 StrictMode의 이중 렌더나 remount에서도 같은 인스턴스를 씁니다.

Provider도 첫 $pageview를 직접 보냅니다. 끄려면 initialPageView={false}를 주세요. 라우트가 바뀔 때는 usePageView()가 돌려주는 함수를 호출합니다. 쓸 수 있는 hook은 useReopt, useReoptClient, useTrack, useIdentify, usePageView, useConsent, useTrackOnMount, usePageViewOnMount입니다.

Provider가 unmount돼도 클라이언트는 닫히지 않습니다. 클라이언트는 페이지와 수명이 같고, StrictMode 시뮬레이션이나 layout 교체 때 큐를 잃으면 안 되기 때문입니다. 닫아야 한다면 client.close()를 직접 호출하세요.

Next.js App Router

App Router에서는 라우터가 페이지뷰를 알려줘야 하므로 전용 진입점을 씁니다. ReoptPageView는 children을 감싸지 말고 형제로 두세요. 감싸면 이동할 때마다 트리 전체가 다시 렌더됩니다.

// app/layout.tsx (서버 컴포넌트)
import { getBootstrap } from "@/lib/reopt";
import { ReoptProvider, ReoptPageView, ReoptWebVitals } 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 />
          <ReoptWebVitals />
          {children}
        </ReoptProvider>
      </body>
    </html>
  );
}

bootstrap은 서버가 읽은 device·동의 상태입니다. 이 값이 있으면 브라우저는 첫 페이지뷰부터 서버와 같은 device ID를 쓰고, 저장소를 읽는 경쟁이 사라집니다. 서버가 방문자를 모르면 null이며, 그때는 클라이언트가 자체 identity를 씁니다. getBootstrap()은 요청의 쿠키를 읽습니다. 결과에 방문자의 device ID가 들어 있으므로 여러 방문자가 공유하는 캐시 안에서 부르거나 그 값을 캐시하지 마세요.

baseUrl: "/ingest"는 아래 proxy와 함께 쓰는 값입니다. proxy.ts를 쓰지 않는다면 리옵트 데이터 오리진을 그대로 넣으세요.

ReoptPageView는 usePathname과 useSearchParams를 함께 구독하므로 쿼리스트링만 바뀌는 필터 변경도 페이지뷰로 셉니다. 자체 <Suspense>를 갖고 있어 상위 레이아웃의 정적 렌더를 깨지 않습니다. 이 진입점의 ReoptProvider는 첫 페이지뷰를 직접 보내지 않습니다. 첫 페이지뷰도 ReoptPageView가 보내므로 두 번 세지 않습니다. ReoptWebVitals는 Next.js가 보고하는 Core Web Vitals를 $web_vitals 이벤트로 보냅니다.

proxy.ts 설정

// proxy.ts  (Next 16에서 middleware.ts를 대체합니다)
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/api/{track,replay/start,replay/chunk} → ${baseUrl}/api/…  (SDK가 보내는 경로만)
});

export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
  • 첫 방문에 device 쿠키를 심습니다. 렌더 전에 심기 때문에 서버와 브라우저가 처음부터 같은 방문자를 봅니다. 동의 전에 쿠키를 쓸 수 없다면 seedDeviceCookie: false로 끄세요.
  • 방문자가 수집을 거부하면 쿠키를 더 쓰지 않는 데 그치지 않고 삭제합니다.
  • /ingest/*를 여러분의 도메인에서 받아 넘깁니다. 애드블로커와 서드파티 쿠키 차단은 1st-party 경로를 다르게 취급합니다.
  • 넘기는 대상은 baseUrl 오리진에서 SDK가 실제로 보내는 세 경로 — /api/track, /api/replay/start, /api/replay/chunk — 뿐입니다. 그 밖의 경로로 풀리는 요청은 400으로 거절합니다.
  • 사이트의 Cookie·Authorization 헤더는 넘기지 않습니다. 리옵트 데이터는 배치를 자체 헤더로 인증하므로 사이트의 세션 쿠키가 필요 없습니다.
  • 브라우저 SDK에 identity.cookieDomain을 지정했다면 reoptProxy에도 같은 값을 cookieDomain으로 넘기세요. 다르면 같은 이름의 쿠키가 두 개 생겨 브라우저와 서버가 서로 다른 방문자를 볼 수 있습니다.
  • matcher를 허용 목록으로 쓰는 앱은 "/ingest/:path*"를 목록에 넣어야 합니다. 빠지면 그 경로에서 proxy가 실행되지 않아 수집 요청이 리옵트 데이터로 전달되지 않습니다.
  • 이미 proxy.ts가 있다면 reoptProxy(...)가 돌려주는 함수에 요청과 여러분의 응답을 차례로 넘기세요. 예: withReopt(request, response).

서버와 서버리스에 붙이기

서버에서는 @reopt-ai/data-sdk-server를 사용합니다. 브라우저 코드와 서버 코드를 섞지 않도록 패키지를 둘로 나눴습니다. 이 패키지는 server-only를 import하므로, client component에서 실수로 가져다 쓰면 빌드가 실패합니다.

진입점쓰는 곳
@reopt-ai/data-sdk-server서버 컴포넌트, route handler, instrumentation.ts
@reopt-ai/data-sdk-server/proxyproxy.ts
@reopt-ai/data-sdk-server/node워커, 크론, 스크립트
@reopt-ai/data-sdk-server/aiVercel AI SDK 텔레메트리

Next.js 안에서 (요청 스코프)

// 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!,
  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(request: Request) {
  const body = await request.json();
  const reopt = await getReopt();

  const queued = reopt.track("checkout_completed", {
    orderId: body.orderId,
    amount: body.amount,
  });

  return Response.json({ ok: queued.queued, eventId: queued.eventId });
}

이 클라이언트는 요청을 보낸 방문자에게 이미 묶여 있습니다.

  • 브라우저가 심어 둔 device 쿠키를 읽어 이벤트에 실으므로, 서버에서 확정되는 전환이 그 방문자의 현재 세션에 들어갑니다.
  • getProfileId는 요청당 한 번만 실행되고, 그 결과가 쿠키의 profileId를 덮어씁니다. 쿠키는 브라우저가 쓰는 값이고 세션은 그렇지 않기 때문입니다.
  • 방문자가 수집을 거부한 상태면 아무것도 보내지 않는 클라이언트를 돌려줍니다. 호출부에 동의 분기가 필요 없습니다.
  • Vercel에서는 요청의 waitUntil을 자동으로 찾아 전송을 넘깁니다(따로 설치할 패키지는 없습니다). 이때는 flush()를 직접 부르지 않아도 이벤트가 유실되지 않습니다.
  • waitUntil이 없고 오래 실행되는 서버에서는 SDK의 배칭 타이머가 보냅니다. 응답 직후 멈추는 다른 서버리스 호스트라면 createReopt({ waitUntil })로 호스트의 waitUntil을 넘기거나 await reopt.flush()를 호출하세요.
  • release 옵션을 주면 track() 이벤트에 $release_id가 붙습니다.
  • clientId와 clientSecret이 없으면 경고를 한 번 남기고 서버 이벤트를 끕니다. 페이지는 실패하지 않습니다.

요청 스코프 클라이언트가 제공하는 것은 track, identify, increment, decrement, flush, 그리고 읽기 전용 deviceId·profileId입니다. trackAndFlush 계열은 아래 Node 진입점에만 있습니다.

createReopt()는 모듈 스코프에서 한 번만 호출하세요. 요청마다 다시 만들면 배칭과 요청 단위 캐시를 잃습니다. 한 배포로 여러 프로젝트를 처리한다면 writeKey에 함수를 주고 credentials resolver를 함께 넘기세요. 둘 다 요청당 한 번만 실행되고, null을 돌려주면 그 요청은 아무것도 기록하지 않습니다.

Next.js 밖에서 (워커·크론·스크립트)

읽을 요청이 없으므로 방문자에게 묶을 수 없습니다. flush는 프로세스가 직접 해야 합니다.

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!,
  batch: { size: 20, intervalMs: 5_000 },
});

const delivery = await reopt.trackAndFlush("nightly_report_generated", { rows: 12_400 });
if (!delivery.queue.queued || delivery.flush?.status !== "success") {
  console.warn("이벤트가 전송되지 않았습니다", delivery);
}

await reopt.close(); // 프로세스 종료 전 필수. 타이머를 멈추고 큐를 비웁니다

trackAndFlush, identifyAndFlush, incrementAndFlush, decrementAndFlush는 큐잉 결과와 전송 결과를 한 번에 반환합니다.

type DeliveryResult = {
  queue: {
    eventId: string;
    queued: boolean;
    reason?: "tracking_paused" | "consent_denied" | "validation_failed" | "payload_too_large";
    errors?: Array<{ field: string; message: string }>;
  };
  flush: {
    status: "idle" | "success" | "failed" | "skipped";
    sent: number;
    failed: number;
    pending: number;
  } | null;
};

queue.queued가 false이면 이벤트가 로컬 큐에 들어가지 않은 상태이므로 flush는 null입니다. flush.status가 failed이면 pending도 함께 확인하세요. 네트워크 오류, 5xx, rate limit은 재시도한 뒤에도 실패하면 배치를 큐에 남기고, quota 초과도 배치를 남깁니다. 401이나 400처럼 서버가 영구히 거절한 배치는 버리고 failed에 집계하며, 콘솔에 [reopt] dropping events 경고를 한 번 남깁니다. 서버 모드에서 일부 행만 거절된 경우에는 그 행 수가 failed에, 나머지가 sent에 들어갑니다.

특정 방문자의 전환을 나중에 보내야 한다면 이벤트에 그 방문자의 device를 실으세요. 요청 시점에 readDeviceId()로 쿠키에서 읽어 두었다가, 보낼 때 identity로 넘깁니다. 읽을 쿠키가 없으면 throw하지 않고 null을 돌려줍니다.

// 요청 시점 (Next.js): 누구의 전환인지 기록해 둡니다
import { cookies } from "next/headers";
import { readDeviceId } from "@reopt-ai/data-sdk-server";

const deviceId = readDeviceId(await cookies(), { writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY! });
// 나중에 (워커): 그 device로 보냅니다
reopt.track({ name: "order_confirmed", properties: { orderId }, identity: { deviceId } });

서버 SDK 응답 해석하기

trackAndFlush 계열의 응답은 두 단계로 나눠 읽습니다.

위치의미
queue.queuedSDK 로컬 큐에 이벤트가 들어갔는지 나타냅니다.
queue.reason큐에 들어가지 않았다면 이유를 알려줍니다.
flush.status네트워크 전송이 성공했는지 나타냅니다.
flush.sent이번 flush에서 전송된 이벤트 수입니다.
flush.pending아직 SDK 큐에 남아 있는 이벤트 수입니다.

워커와 스크립트에서는 queue.queued === true와 flush.status === "success"를 함께 확인하세요. 둘 중 하나라도 기대와 다르면 작업을 성공으로 끝내지 말고 재시도 정책을 세우는 편이 안전합니다.

로그인 사용자 연결하기

로그인 전에는 deviceId를 기준으로 익명 행동을 묶습니다. 로그인 직후 identify(profileId, properties)를 호출하면 이후 이벤트가 식별된 사용자 프로필과 연결됩니다.

로그아웃할 때는 reset()을 호출하세요. 현재 프로필 ID, 큐, register()로 붙인 속성을 비우고 새 device ID를 발급합니다. 공용 컴퓨터에서 다음 사용자가 이전 사용자의 기록을 이어받지 않게 하기 위해서입니다.

동의와 수집 중단

기본 동의 모델은 opt-out입니다. 아무 설정이 없으면 analytics 카테고리가 허용된 상태로 시작합니다.

import { setConsent, pauseTracking, resumeTracking } from "@reopt-ai/data-sdk-client";

setConsent("analytics", false); // 수집 거부: 이후 이벤트는 큐에 들어가지 않습니다
setConsent("analytics", true); // 다시 허용

pauseTracking(); // 일시 중지. 동의 상태는 바꾸지 않습니다
resumeTracking();
  • analytics를 거부하면 SDK가 device 쿠키를 지웁니다. proxy도 이후 요청에서 남아 있는 device 쿠키를 삭제하고, 서버 SDK의 getReopt()는 그 방문자에게 아무것도 보내지 않는 클라이언트를 돌려줍니다.
  • 동의 결정은 기본으로 reopt_<writeKey>_consent 쿠키에 저장되어 서버도 같은 값을 읽습니다.
  • 동의 전에는 수집하지 않으려면 consent: { defaultConsent: false }로 시작하고, 동의를 받은 뒤 setConsent("analytics", true)를 호출하세요.
  • 자체 동의 배너가 결정을 관리한다면 consent: { persist: false }로 SDK가 쿠키를 따로 두지 않게 하세요.

SDK가 자동으로 붙이는 기본 메타데이터

SDK는 각 이벤트에 eventId와 timestamp를 붙이고, 요청 헤더에는 reopt-device-id를 보냅니다.

  • eventId는 프로젝트 안에서 이벤트를 구분하는 값입니다. 같은 프로젝트에 같은 eventId가 다시 들어오면 새로 저장하지 않고 duplicate로 처리합니다.
  • timestamp는 클라이언트 생성 시각입니다. 서버는 별도로 수신 시각도 저장합니다.
  • deviceId는 로그인 전 익명 행동을 세션/프로필 후보와 연결하는 기준입니다.
  • 브라우저는 device ID와 동의 상태를 쿠키(reopt_<writeKey>_device, reopt_<writeKey>_consent)에 저장합니다. 서버도 같은 값을 읽어야 첫 렌더부터 서버와 브라우저가 같은 방문자를 보기 때문입니다. offline queue는 localStorage에 둡니다.
  • 쿠키 이름이 writeKey에서 유도되므로 한 도메인에 두 프로젝트를 붙여도 서로의 방문자를 덮어쓰지 않습니다.
  • localStorage가 막힌 환경에서도 device ID는 쿠키에 남지만, offline queue의 영속성은 보장되지 않습니다.
  • 수집 응답이 내려준 서명된 세션 값은 SDK가 device 쿠키에 보관했다가 다음 요청의 reopt-session-id 헤더로 보냅니다.

SDK가 스스로 보내는 이벤트는 다음과 같습니다.

이벤트보내는 시점주요 속성
$pageview초기화 시, 그리고 pageView()나 ReoptPageView가 호출될 때path, origin, title, referrer, utm_*
$pageleave페이지를 떠나거나 숨길 때, 다음 페이지뷰가 들어올 때path, origin, duration(초), scroll_depth
$web_vitalsReoptWebVitals를 두었거나 captureWebVital()을 호출할 때metric_name, value, rating, path
$exceptioncapture.exceptions를 켰거나 captureException()을 호출할 때$exception_type, $exception_message, path
$screen_viewscreenView(name)을 호출할 때screen_name

에러 수집 켜기

브라우저에서는 옵션 하나로 켭니다.

init({
  writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
  baseUrl: process.env.NEXT_PUBLIC_REOPT_API_URL!,
  capture: { exceptions: true },
  release: process.env.NEXT_PUBLIC_APP_VERSION,
});
  • 잡히지 않은 에러와 unhandled rejection이 $exception 이벤트로 들어옵니다. 스택 파서는 별도 chunk라서 이 기능을 쓰지 않는 페이지의 초기 번들에는 들어가지 않습니다.
  • 자동 수집은 같은 타입의 예외가 몰릴 때 전송량을 제한합니다. 기본값은 예외 타입별로 10건까지 보내고 10초마다 1건씩 다시 허용하는 방식이며, capture.exceptionRateLimit으로 바꾸거나 false로 끌 수 있습니다.
  • 스택이 전부 브라우저 확장 프로그램 프레임인 에러는 자동 수집에서 제외합니다.
  • 직접 잡은 에러는 captureException(error)로 보냅니다. 이 호출은 조절 대상이 아닙니다.
  • release를 주면 이슈가 어느 빌드에서 처음 나타났는지, 고친 뒤 다시 나타났는지를 구분할 수 있습니다.

Next.js 서버 에러는 instrumentation.ts에서 붙입니다.

// 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!,
  beforeCapture: () => ({ $release_id: process.env.VERCEL_GIT_COMMIT_SHA }),
});

beforeCapture가 객체를 돌려주면 속성에 합치고, false를 돌려주거나 throw하면 그 에러는 보내지 않습니다.

압축된 스택을 원본 위치로 되돌리려면 배포 파이프라인에서 소스맵을 올리세요. 명령은 CLI 사용하기에 있습니다. 수집된 에러가 이슈로 묶이는 방식과 화면은 에러 트래킹에서 다룹니다. $exception 이벤트도 1건당 데이터 포인트 1개로 집계됩니다.

세션 리플레이 켜기

세션 리플레이는 다음 세 가지를 모두 갖춰야 녹화됩니다.

  1. 프로젝트의 세션 리플레이 화면에서 녹화 설정을 켭니다. 수집 비율도 여기서 정합니다. 설정 변경은 owner 또는 admin만 할 수 있습니다.
  2. SDK에서 sessionReplay: { enabled: true }를 줍니다.
  3. 방문자가 녹화에 동의하면 setConsent("replay", true)를 호출합니다. analytics 동의도 함께 있어야 합니다.
const client = init({
  writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
  baseUrl: "/ingest",
  sessionReplay: { enabled: true },
});

// 동의 UI가 방문자의 녹화 결정을 받은 뒤에만 호출합니다
client.setConsent("replay", true);
  • replay 동의는 기본으로 주어지지 않습니다. setConsent("replay", true)를 호출하기 전에는 녹화하지 않습니다. 기본 허용되는 consent.categories 목록에 replay를 넣지 마세요.
  • 입력값과 화면 텍스트는 가려서 기록합니다. data-reopt-replay-block 또는 data-reopt-private 속성을 단 영역은 통째로 제외하고, sessionReplay.blockSelector로 제외할 영역을 더할 수 있습니다.
  • 녹화 코드는 켰을 때만 별도 chunk로 로드됩니다.
  • 업로드는 baseUrl 아래 /api/replay/start와 /api/replay/chunk로 갑니다. /ingest proxy를 쓰고 있다면 그대로 전달됩니다.
  • flushIntervalMs는 전송 주기로, 기본 20초이며 130초 사이에서 조정합니다. idleTimeoutMs는 유휴 중단 시간으로, 기본 60초이며 10초5분 사이에서 조정합니다.

녹화 목록과 재생 화면은 세션 리플레이에서 다룹니다.

AI 텔레메트리 켜기

Vercel AI SDK(ai@7)를 쓰는 서버라면 텔레메트리를 한 번 등록하세요. generateText, streamText, embed와 도구 실행이 호출부 수정 없이 기록됩니다.

// instrumentation.ts (또는 에이전트 워커의 진입점)
import { registerTelemetry } from "ai";
import { createReoptAiTelemetry } from "@reopt-ai/data-sdk-server/ai";
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!,
});

export const aiTelemetry = createReoptAiTelemetry({ reopt });
registerTelemetry(aiTelemetry);
  • 프롬프트와 응답 본문은 기본으로 보내지 않습니다. 본문까지 보려면 SDK의 captureContent: true와 프로젝트 설정의 본문 수집을 모두 켜야 합니다.
  • redact로 본문과 에러 메시지를 내보내기 전에 가릴 수 있습니다. throw하면 그 필드는 보내지 않습니다.
  • sampleRate(0~1)는 trace ID 기준이라 한 trace가 통째로 남거나 통째로 빠집니다.
  • 세션과 사용자 축은 context 옵션으로 매핑합니다.
  • AI SDK 밖의 호출은 aiTelemetry.withAiTrace(), captureAiGeneration(), captureAiSpan(), captureAiFeedback()으로 직접 기록합니다.
  • 스크립트를 끝내기 전에는 await aiTelemetry.flush() 뒤에 await reopt.close()를 호출하세요.

AI 이벤트($ai_generation, $ai_trace, $ai_span, $ai_embedding, $ai_feedback)도 각각 데이터 포인트 1개로 집계됩니다. 수집된 호출을 보는 화면은 AI 관측에서 다룹니다.

SDK devtool로 전송 내용 확인하기

@reopt-ai/data-sdk-devtool은 브라우저 SDK가 큐에 넣은 이벤트, 보낸 배치, 서버 응답, identity 변화를 페이지 안의 패널로 보여줍니다. 여러분의 앱에 붙이는 패키지라서, 개발 중인 화면에서 SDK가 실제로 무엇을 보내는지 바로 확인할 수 있습니다.

pnpm add -D @reopt-ai/data-sdk-devtool
// lib/devtools.ts: 인스턴스 하나를 SDK 설정과 패널이 함께 씁니다
import { createDevtools } from "@reopt-ai/data-sdk-devtool";

export const devtools = createDevtools();
"use client";

import { ReoptProvider } from "@reopt-ai/data-sdk-client/next";
import { ReoptDevtools } from "@reopt-ai/data-sdk-devtool/react";
import { devtools } from "@/lib/devtools";

export function Analytics({ children }: { children: React.ReactNode }) {
  return (
    <ReoptProvider
      config={{
        writeKey: process.env.NEXT_PUBLIC_REOPT_WRITE_KEY!,
        baseUrl: "/ingest",
        fetch: devtools.fetch,
        observe: devtools.observe,
      }}
    >
      {children}
      <ReoptDevtools devtools={devtools} />
    </ReoptProvider>
  );
}
  • 프로덕션에서는 기본으로 꺼져 있습니다. NODE_ENV가 production이면 아무것도 기록하지 않고 패널도 그리지 않습니다. 스테이징이나 데모에서 켜려면 createDevtools({ enabled: true })를 씁니다.
  • 자격증명 헤더와 흔한 개인정보 필드는 저장 전에 가립니다. 원본 값이 필요하면 captureRaw: true를 쓰되 신뢰할 수 있는 로컬 페이지에서만 켜세요.
  • Provider 없이 init()을 쓰는 앱에서도 같은 fetch와 observe를 넘기면 됩니다.

직접 HTTP로 호출하기

SDK를 통하지 않고 POST /api/track를 호출할 때도 같은 이벤트 형식을 지켜야 합니다. track, identify, increment, decrement 이벤트에는 UUID eventId와 millisecond timestamp가 필수입니다. alias 타입은 현재 수집 API에서 지원하지 않습니다.

[
  {
    "type": "track",
    "eventId": "550e8400-e29b-41d4-a716-446655440000",
    "timestamp": 1716000000000,
    "payload": {
      "name": "signup_clicked",
      "properties": {
        "source": "landing"
      },
      "profileId": "user_123"
    }
  }
]

브라우저 키로 보낼 때는 reopt-write-key를, 서버 키로 보낼 때는 reopt-client-id와 reopt-client-secret을 헤더에 넣습니다. reopt-device-id는 익명 사용자와 세션 연결 품질을 높이므로 직접 호출에서도 보내는 것이 좋습니다. 서버 자격증명으로 호출하면서 이 헤더를 보내면, 그 이벤트는 해당 device의 활성 세션에 합류합니다. 몇 시간 뒤 도착한 웹훅처럼 활성 세션이 없을 때는 세션 없이 적재하고 새 세션을 만들지 않습니다. pageview가 0인 세션이 생기면 이탈률이 왜곡되기 때문입니다.

서버 자격증명으로 보내는 배치에서는 이벤트마다 deviceId 필드를 둘 수도 있습니다. 한 배치에 여러 방문자의 전환이 섞여 있을 때 쓰며, 요청 헤더보다 우선합니다. 브라우저 키로 보낸 요청에서는 이 필드를 무시합니다.

reopt-session-id는 서버가 서명한 값만 인정합니다. 수집 응답의 session으로 내려온 <세션 ID>.<토큰>을 그대로 돌려보낸 경우에만 그 세션에 합류합니다. 서명이 없거나 맞지 않는 값은 조용히 무시하고 device 기준으로 세션을 정합니다. 누구나 임의 값을 넣을 수 있는 헤더를 그대로 믿으면 남의 세션에 이벤트를 주입할 수 있기 때문입니다.

직접 호출의 최소 예시는 다음과 같습니다.

curl -X POST "$REOPT_BASE_URL/api/track" \
  -H "content-type: application/json" \
  -H "reopt-write-key: $NEXT_PUBLIC_REOPT_WRITE_KEY" \
  -H "reopt-device-id: dev-device-1" \
  --data '{
    "type": "track",
    "eventId": "550e8400-e29b-41d4-a716-446655440000",
    "timestamp": 1716000000000,
    "payload": {
      "name": "signup_clicked",
      "properties": { "source": "docs" }
    }
  }'

server 모드의 2xx 응답은 accepted + duplicates + rejected 개수 = 보낸 이벤트 수를 만족합니다. 로그를 순서대로 다시 보내는 서버는 이 식으로 빠진 이벤트가 없는지 확인할 수 있습니다.

수집 요청 뒤에 이어지는 처리

  1. 자격증명과 프로젝트의 분당 요청 한도를 확인합니다.
  2. 이벤트 형식을 검증하고, 브라우저 수집이면 5초 중복 제거를 적용합니다.
  3. 조직의 데이터 포인트 한도를 확인합니다. 배치 전체가 남은 한도에 들어와야 접수됩니다.
  4. 원본 이벤트를 저장하고 응답을 돌려줍니다.
  5. 그 뒤에 세션과 프로필을 정리하고 분석 화면과 실시간 스트림에 반영합니다.

분석 반영이 중간에 실패해도 저장된 원본을 기준으로 다시 처리하므로, 수집에 성공한 이벤트가 사라지지는 않습니다.

브라우저 요청은 서버가 사람, 의심 트래픽, 알려진 봇으로 분류합니다. 사람이 아닌 트래픽은 세션·프로필·기본 분석과 데이터 포인트 집계에 들어가지 않습니다.

이벤트 이름과 속성 정하기

  • 이벤트 이름은 사용자 행동을 과거형 동사로 표현합니다. 예: signup_clicked, checkout_completed
  • $로 시작하는 이름은 SDK가 쓰는 이벤트입니다. $pageview, $pageleave, $screen_view, $web_vitals, $exception과 AI 텔레메트리의 $ai_*가 있습니다.
  • session_start, session_end, screen_view는 예약된 이름이라 직접 보내면 거절됩니다.
  • 속성 값은 문자열, 숫자, boolean, null, 배열, object처럼 JSON으로 직렬화 가능한 값만 사용합니다.
  • 결제 금액, 플랜, 실험군처럼 분석 축이 되는 값은 이벤트 속성에 명시적으로 넣습니다.
  • 이메일, 전화번호, 주소, access token 같은 민감 정보는 이벤트 속성에 넣지 않습니다.

주의할 점

  • serverSecret은 서버 전용입니다. 공개 환경에 포함되면 안 됩니다.
  • 워커나 스크립트에서 track()만 호출하고 프로세스를 끝내면 이벤트 전송이 완료되지 않을 수 있습니다. trackAndFlush 계열을 쓰거나 종료 전에 close()를 호출하세요.
  • 같은 eventId를 재사용하면 새로 저장되지 않고 duplicate로 처리됩니다.
  • accepted가 0이어도 duplicates가 1 이상이면 요청 자체는 정상일 수 있습니다.
  • init()이나 Provider가 첫 $pageview를 이미 보냅니다. 같은 화면에서 pageView()를 다시 부르면 두 번 집계됩니다.
  • 이벤트 속성에는 개인식별정보와 인증 토큰을 넣지 마세요.

온보딩 검증 체크리스트

첫 배포 전에는 아래 순서로 확인하세요.

  1. 개발 또는 preview 환경에서 debug: true로 큐잉 결과를 확인합니다.
  2. 네트워크 탭에서 POST /api/track 응답이 200인지 확인합니다.
  3. 응답의 accepted가 증가하고 duplicates가 비정상적으로 높지 않은지 봅니다.
  4. 프로젝트의 이벤트 스트림 또는 데이터 카탈로그에서 이벤트 이름과 속성이 기대한 형태인지 확인합니다.
  5. 로그인 직후 identify가 들어오고 이후 이벤트에 profileId가 붙는지 확인합니다.
  6. 워커·크론 이벤트는 trackAndFlush 계열을 사용해 flush.status === "success"까지 확인합니다.
  7. 데이터 카탈로그에서 전환, 태그, 카테고리, 상태를 정리합니다.

정상 응답 예시

수집 API가 정상 처리하면 다음과 같은 응답을 반환합니다.

{
  "status": "ok",
  "requestId": "...",
  "mode": "browser",
  "accepted": 1,
  "duplicates": 0,
  "rejected": [],
  "queued": true
}

accepted는 이번 요청에서 새로 저장된 이벤트 수입니다. duplicates가 1 이상이면 같은 eventId가 이미 들어온 상태일 수 있습니다. 브라우저 모드에서는 5초 중복 제거로 합쳐진 이벤트도 여기에 들어갑니다. rejected는 서버 모드에서 행별로 거절된 이벤트 목록입니다. 각 항목의 reason은 validation_failed, reserved_name, duplicate_in_batch 중 하나입니다. 다시 보내도 결과가 같으므로 재시도하지 마세요. queued가 true이면 분석 화면에 반영할 작업이 예약된 것입니다. 브라우저 모드 응답에는 session(세션 ID와 서명 토큰)이 함께 올 수 있습니다. 세션의 첫 배치에는 없는 것이 정상입니다.

드물게 202와 "status": "accepted"가 돌아올 수 있습니다. 이벤트는 저장됐고 화면 반영만 조금 늦어지는 경우이므로 성공으로 처리하세요.

오류 응답은 code로 구분하세요. error와 message는 사람이 읽는 문장이라 바뀔 수 있습니다.

HTTPcode의미
400invalid_json본문이 JSON이 아닙니다.
400payload_too_large본문이 512,000 bytes를 넘습니다. 배치를 나누세요.
400empty_batch이벤트가 0건입니다.
400alias_unsupportedtype: "alias" 이벤트가 들어 있습니다.
400validation_failed브라우저 모드에서 이벤트 하나가 검증에 실패했습니다.
401unauthorized자격증명이 없거나 맞지 않습니다.
410project_purging삭제 중인 프로젝트입니다. 더 보내지 마세요.
429rate_limited프로젝트 요청 한도입니다. Retry-After 뒤에 다시 보냅니다.
429quota_exceeded조직의 월간 데이터 포인트 한도입니다. 사람이 한도를 올려야 풀립니다.
500internal_error서버 오류입니다. 간격을 두고 재시도하세요.

자주 보는 문제

증상확인할 것해결 방향
401 응답브라우저에는 writeKey, 서버에는 clientId + serverSecret을 썼는지 확인환경변수 이름과 헤더를 분리하세요. clientId는 writeKey가 아닙니다.
400 validation_failedeventId, timestamp, 이벤트 타입별 payload가 수집 형식에 맞는지 확인SDK를 쓰거나 직접 HTTP payload를 수집 형식에 맞추세요.
429 rate_limited프로젝트당 분당 요청 수이벤트를 배치로 묶어 요청 수를 줄이세요.
429 quota_exceeded조직의 월간 데이터 포인트 사용량사용량 및 요금 화면에서 플랜과 한도를 확인하세요.
콘솔에 [reopt] dropping events서버가 배치를 영구히 거절한 상태자격증명과 baseUrl을 확인하세요.
이벤트가 큐에는 들어가지만 안 보임flush.status가 success인지, 응답의 accepted가 1 이상인지 확인이벤트 스트림에서 먼저 확인하세요. 집계 화면 반영은 조금 늦습니다.
페이지뷰가 두 배로 잡힘초기화 뒤 pageView()를 한 번 더 부르는지 확인호출을 지우거나 capture.pageview를 false로 두세요.
같은 이벤트가 반복적으로 duplicate같은 eventId를 재사용하고 있지 않은지 확인이벤트마다 새 UUID를 생성하세요.
사용자가 연결되지 않음로그인 직후 identify와 이후 이벤트의 profileId 확인로그인 성공 직후 identify(profileId)를 호출하세요.
리플레이가 쌓이지 않음프로젝트 녹화 설정, SDK 옵션, replay 동의 세 가지셋 중 빠진 것을 켜세요. 수집 비율에 따라 일부 세션만 녹화됩니다.

다음 단계