본문으로 건너뛰기
Sursa Developers

데이터 웨어하우스로 내보내기

분석가는 Sursa 데이터를 회사의 다른 데이터와 함께 보고 싶어 하는 경우가 많아요. 어떤 카드가 검증되었고 누가 검증했는지, 팀별로 지식이 어떻게 늘어나는지, 어떤 질문이 아직 답변되지 않았는지 같은 것들이에요. 이 가이드에서는 Sursa 데이터를 데이터 웨어하우스로 복사하고 사본을 최신 상태로 유지하는 예약 작업을 만들어요.

작업은 API의 세 가지 요소를 조합해요.

요소제공하는 것
updated_since를 쓰는 카드 목록과 통째로 읽는 짧은 목록지난 실행 이후 바뀐 객체의 현재 상태.
이벤트 피드30일 동안의 삭제와 일어난 일의 이력.
커서빠지거나 중복되는 항목이 없는 완전한 페이지.

cards:read와 users:read 스코프, 그리고 이벤트 피드용 webhooks:manage 스코프가 있는 키가 필요해요. 작업 전용 키를 admin 역할로 만드세요. 전체 사용자 목록과 이벤트 피드는 이 역할에만 열려 있어요. 더 좁은 역할과 데이터 프로필의 키는 특정 그룹이 볼 수 있는 내용만 내보내요. 이런 키는 요약 사용자 목록(brief=true)을 읽고 이벤트 피드는 읽지 못하므로, 삭제는 아래의 매주 전체 적재로 반영해요.

테이블

객체마다 테이블 하나, 이벤트용 테이블 하나를 두는 간단한 구성이에요.

테이블키가져오는 엔드포인트
sursa_cardsidGET /v1/cards
sursa_collectionsidGET /v1/collections
sursa_usersidGET /v1/users
sursa_groupsidGET /v1/groups
sursa_eventsidGET /v1/webhook-events

각 객체는 받은 그대로 JSON 열에 저장하고, 자주 조회하는 열(updated_at, collection_id, status, owner_id)을 따로 두세요. 이후 API 버전에서 새 필드가 추가되더라도 JSON 열이 있으면 작업을 바꾸지 않고 새 필드를 받을 수 있어요.

최초 적재

updated_since 없이 모든 목록을 끝까지 읽고 모든 객체를 쓰세요. 적재가 시작된 시각을 기억해 두세요. 다음 실행은 그 시각부터 이어져요.

증분 적재

실행할 때마다(예: 15분마다) 다음을 수행하세요.

  1. 이번 실행의 시작 시각을 기억해 두세요.
  2. 카드는 이전 실행의 시작 시각에서 1분의 여유 시간을 뺀 updated_since 값으로 읽고, 다음 페이지마다 커서와 같은 updated_since 값을 함께 보내 요청하세요(첫 페이지의 필터 없이 보낸 커서는 400 api.cursor_invalid 오류를 반환해요). 컬렉션, 사용자, 그룹은 통째로 읽으세요. 컬렉션과 사용자에는 updated_at이 없고, 그룹의 updated_at은 멤버가 바뀌어도 바뀌지 않아요. 각 객체를 id 기준으로 생성 또는 교체하세요.
  3. 지난번에 저장한 커서부터 이벤트 피드를 읽으세요. 각 이벤트를 sursa_events에 추가하고 삭제를 적용하세요. card.deleted 이벤트가 오면 sursa_cards에서 카드를 지우거나, 이력을 보관한다면 삭제된 것으로 표시하세요.
  4. 시작 시각과 새 피드 커서를 저장하세요. 웨어하우스가 지원한다면 데이터와 같은 트랜잭션에서 저장하세요.
import json
from datetime import datetime, timedelta, timezone

LISTS = {"cards": "/v1/cards", "collections": "/v1/collections", "users": "/v1/users", "groups": "/v1/groups"}


def read_list(path, since):
    """Every object of a list, changed since `since` (or all of them). The next page keeps the filters of the first."""
    params = {"limit": 200}
    if since:
        params["updated_since"] = (since - timedelta(minutes=1)).isoformat()
    while params:
        response = session.get(BASE + path, params=params, timeout=120)
        response.raise_for_status()
        body = response.json()
        items = body["data"] if isinstance(body, dict) and "data" in body else body
        yield from items
        if isinstance(body, dict) and "data" in body:
            params = {**params, "cursor": body["next_cursor"]} if body.get("has_more") else None
        else:
            cursor = response.headers.get("X-Next-Cursor")
            params = {**params, "cursor": cursor} if cursor else None


def read_events(cursor):
    """New events of the feed after `cursor` (or from the start of the 30 days), and the cursor to save."""
    events = []
    params = {"limit": 200, **({"cursor": cursor} if cursor else {})}
    while True:
        page = api("GET", "/v1/webhook-events?" + requests.compat.urlencode(params))
        events += page["data"]
        params["cursor"] = page["next_cursor"]
        if not page["has_more"]:
            return events, page["next_cursor"]


def run(state, warehouse):
    started_at = datetime.now(timezone.utc)
    since = state.get("since")
    for table, path in LISTS.items():
        for obj in read_list(path, since if table == "cards" else None):
            warehouse.upsert(f"sursa_{table}", obj["id"], json.dumps(obj))

    events, feed_cursor = read_events(state.get("feed_cursor"))
    for event in events:
        warehouse.insert_once("sursa_events", event["id"], json.dumps(event))
        if event["type"] == "card.deleted":
            warehouse.delete("sursa_cards", event["object"]["id"])

    warehouse.save_state({"since": started_at, "feed_cursor": feed_cursor})

피드 커서는 마지막 페이지를 포함해 모든 페이지에서 반환돼요. 이 커서를 저장하면 다음 실행이 마지막으로 저장한 이벤트 바로 뒤에서 시작되므로, 이벤트를 두 번 읽거나 건너뛰지 않아요.

사본을 정확하게 유지하기

  • 30일 넘게 실행하지 않은 경우. 피드는 이벤트를 30일 동안 보관해요. 작업을 그보다 오래 실행하지 않았다면 전체 적재를 다시 실행하고, 전체 적재에서 반환되지 않은 모든 객체를 테이블에서 지우세요.
  • 매주 전체 적재. 피드가 정상이더라도 일주일에 한 번 전체 적재를 하고 테이블과 비교하면, 적은 비용으로 사본이 완전한지 확인할 수 있어요.
  • 키가 볼 수 없는 것은 내보내지 않아요. 작업에는 키가 보는 것만 정확히 보여요. 예를 들어 카드가 제한된 컬렉션으로 옮겨져 키의 접근 범위를 벗어나면 전체 적재에서 반환되지 않아요. 이런 카드는 제거된 것으로 처리하세요.
  • 질문. 질문 기록은 따로 내보낼 수 있어요. GET /v1/questions/export.csv는 기간 내의 질문을 평가와 출처를 포함한 CSV 파일 하나로 반환해요. ask 스코프가 필요해요.

한도 지키기

큰 조직의 전체 적재는 요청을 많이 보내요. limit=200으로 목록을 하나씩 읽고 RateLimit 헤더를 지켜보세요. 변경 사항만 읽는 15분 간격 작업은 어떤 할당량에도 한참 못 미쳐요.