7 minute read

3주차에는 설계(Deployment Diagram)에서 정한 범위를 실제로 구현해 보고, tk-*.log 기반으로 에러 감지 → 시그니처 생성(중복 회피) → 로그 묶음 → PMS 티켓 생성 까지의 최소 동작을 확인했다.

구현은 PMS Desktop client(sgtk) 환경 전용으로 백그라운드 앱(tk-incidentreporter) 형태로 만들었다.

sgtk 는 PMS Desktop 개발을 위한 환경(프레임워크)이며, 오픈소스로 공개되어 있다.

전체 구조

아래 이미지는 배포 다이어그램으로, 클라이언트(엔진/sgtk/DCC)와 중앙 PMS 관계, 그리고 내가 개발할 범위(tk-incidentreporter)를 보여준다.

toss-observability-deployment.png

개발환경을 쉽게 보여주고자 시각화하였다.

이번 주 목표(MVP 스코프)

  • 멀티 OS(Windows/macOS/Linux)에서 공통 방식으로 지정 로그 경로 하위의 tk-*.log를 스캔하고 tail/follow 한다.
    • Windows: %APPDATA%\Shotgun\logs\
    • Linux: ~/.shotgun/logs/
    • Mac: ~/Library/Logs/Shotgun/
  • 로그 레벨 중 CRITICAL/ERROR를 감지하면, 엔진 단위로 로그를 묶고 실행 환경 정보를 수집한 뒤 PMS에 첨부하여 티켓을 자동 생성한다.
  • 최소 동작(MVP)으로 엔드투엔드 흐름(감지 → 수집/묶음 → 첨부 → 티켓 생성)을 검증한다.

전체 구조

레포의 핵심 파일/모듈은 다음과 같다.

tk-incidentreporter 레포지토리

tk-incidentreporter/
├─ info.yml, app.py
└─ python/tk_incident/
├─ bootstrap.py, autostart.py
├─ singleton.py        # 프로세스 중복 방지
├─ tail_worker.py      # 로그 tail/follow + 롤오버 처리
├─ matcher.py          # ERROR/키워드 감지
├─ agent.py            # 매칭 → 업로드 오케스트레이션
├─ uploader.py         # sgtk로 PMS Ticket 생성 + 로그 첨부
└─ utils.py            # 경로/환경 유틸

개발하면서 고려했던 점들

1) 싱글톤 (중복 실행 방지)

같은 머신에서 Desktop 앱이 여러 개 띄워질 수도 있고, 개발 중 에이전트를 여러 번 실행해 버리는 상황이 발생했다. 그래서 에이전트가 한 프로세스만 동작하도록 싱글톤을 넣었다.

처음에는 파일락을 생각했는데(PID), Desktop 앱이 Qt 기반이라 로컬 IPC인 QLocalServer를 사용했다. 로컬 소켓(Windows는 네임드파이프, Unix는 도메인 소켓)을 이용하면 플랫폼에 관계없이 간단하고 안전하게 이름(name)으로 락을 잡을 수 있다.

from Qt import QtNetwork

class SingletonLock(object):
    def __init__(self, name):
        self.name = name
        self._server = None

    def acquire(self):
        server = QtNetwork.QLocalServer()
        try:
            server.removeServer(self.name)  # stale socket cleanup
        except Exception:
            pass
        ok = server.listen(self.name)
        if ok:
            self._server = server
            return True
        return False

    def release(self):
        if self._server:
            try:
                self._server.close()
            except Exception:
                pass
            self._server = None

2) 로그 tail (TailWorker) + 롤오버 처리

로그 시스템은 파일 회전(rotate)이나 truncate가 발생할 수 있으므로, 각 파일에 대해 바이트 오프셋을 유지하고 inode와 파일 크기를 비교해 안전하게 follow 하도록 했다.

# tail_worker.py
from pathlib import Path
from Qt import QtCore
import time

class TailWorker(QtCore.QThread):
    line_detected = QtCore.Signal(dict)

    def _scan_and_read(self):
        for f in self.log_folder.glob("tk-*.log"):
            pstr = str(f)
            if pstr not in self._files:
                self._files[pstr] = {"pos": f.stat().st_size, "inode": getattr(f.stat(), "st_ino", None)}
        for pstr, info in list(self._files.items()):
            try:
                p = Path(pstr)
                st = p.stat()
                inode = getattr(st, "st_ino", None)
                if info.get("inode") and inode != info.get("inode"):
                    info["pos"] = 0
                    info["inode"] = inode
                if st.st_size < info.get("pos", 0):
                    info["pos"] = 0
                with open(pstr, "r", encoding="utf-8", errors="ignore") as fh:
                    fh.seek(info["pos"])
                    while True:
                        line = fh.readline()
                        if not line:
                            break
                        info["pos"] = fh.tell()
                        payload = {"path": pstr, "line": line.rstrip("\n"), "pos": info["pos"], "ts": time.time()}
                        self.line_detected.emit(payload)
            except FileNotFoundError:
                try:
                    del self._files[pstr]
                except KeyError:
                    pass
            except Exception:
                pass

3) 매치 및 티켓 생성 정책(중복 방지 · 쓰로틀링)

import re

class Matcher(object):
    """
    Minimal matcher: detect ERROR/CRITICAL only.
    """
    def __init__(self, settings=None):
        self.settings = settings or {}
        self.severity_re = re.compile(r"\b(ERROR|CRITICAL)\b")

    def match(self, line):
        m = self.severity_re.search(line)
        if m:
            return {"reason": "severity", "level": m.group(1), "matched_line": line}
        return None

클라이언트 환경은 동일한 오류가 매우 짧은 시간 안에 여러 번 발생할 수 있다. 특히 수정 전의 버전이 배포되어 있는 동안에는 동일한 오류가 여러 클라이언트에서 반복 발생해 티켓이 폭주할 위험이 크다. 이를 막기 위해 Uploader는 다음 원칙으로 설계 및 수정되었다.

핵심 아이디어

  • 시그니처: "<user_login> - <ErrorName or short message>" 동일 사용자·동일 오류는 동일 제목으로 묶어, 중복 티켓 생성 방지.
  • 제목 기반 중복 검사: 생성 전에 PMS에 동일 제목 티켓이 있는지 확인. 있으면 생성/첨부를 건너뛴다.

시그니처 생성 패턴

# 시그니처 패턴
# "<user_login> - <ErrorName or short message>"
# 예: "alice - ValueError"  또는  "bob - Unknown error: file open failed"
def make_signature(user_login: str, matched_line: str) -> str:
    core = extract_error_name(matched_line) or short_text(strip_ts(matched_line), max_len=80)
    title = f"{sanitize(user_login)} - {sanitize(core)}"
    return title

타임스탬프/프리픽스 제거 + 에러 이름 추출

import re, hashlib

def strip_ts(text: str) -> str:
    t = re.sub(r'^\s*\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}:\d{2}(?:,\d+)?\s*', '', text, count=1)
    t = re.sub(r'^\s*\[[^\]]+\]\s*', '', t, count=1)
    return t

def extract_error_name(text: str) -> str | None:
    m = re.search(r'([A-Za-z_][A-Za-z0-9_]*(?:Error|Exception))', text)
    return m.group(1) if m else None

def short_text(text: str, max_len=80) -> str:
    s = " ".join(text.strip().split())
    return s[:max_len] if s else hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]

def sanitize(s: str) -> str:
    return re.sub(r'[\r\n\t]+', ' ', str(s)).strip()

업로드 플로우(요약)

def upload_log_flow(sg, user_login, log_path, trigger):
    # 1) 시그니처 생성
    title = make_signature(user_login, trigger.get("matched_line", ""))

    # 2) 동일 제목 티켓 존재 확인 -> 있으면 종료(중복 방지)
    existing = find_ticket_by_title(sg, "Ticket", title)
    if existing:
        return True

    # 3) 티켓 생성
    payload = {
        "project": {"type": "Project", "id": 190},
        "title": title,
        "description": f"Matched line:\n{trigger.get('matched_line','')}\nDetected at: {trigger.get('detected_ts')}"
    }
    created = sg.create("Ticket", payload)
    ticket_id = created.get("id")

    # 4) 로그 첨부
    ok = attach_log_with_retries(sg, "Ticket", ticket_id, log_path)
    return ok

테스트

  • Desktop에서 tk-incidentreporter 앱을 로드한 뒤, tk-desktop.log / tk-maya.logERROR 라인을 추가 → Agent가 감지하고 티켓을 생성함을 확인했다.
  • 동일 에러를 여러 번 발생시키는 시나리오에서 동일 제목의 티켓이 단 한 건만 생성됨을 확인했다.
  • 로그 파일 rotate / truncate 상황에서 TailWorker가 재열거·재오픈하여 새 로그를 추적하는 것도 확인했다.

실제 PMS에 올라간 모습(현장 검증)

구현 후 가장 체감된 변화는 별도의 로그 요청 없이도 문제가 발생하면 자동으로 티켓이 생성되어 운영자가 바로 확인할 수 있게 된 점이다. 테스트 환경에서 로그를 트리거로 여러 건의 티켓이 PMS에 자동 생성되는 것을 확인했다. 각 티켓에는 감지된 매치 라인과 탐지 시각, 원본 로그 파일이 첨부되어 있어, 별도 로그 요청이나 전달 과정 없이도 즉시 원인 확인과 우선순위 판단이 가능해보인다.

티켓 목록
스크린샷1 — 티켓 목록 동일한 사용자명-에러명 패턴으로 생성된 티켓들이 목록에 쌓여 있다.

티켓 상세
스크린샷2 — 티켓 상세 티켓 본문에서 더 자세한 내용을 확인할 수 있다.

  • 운영적 효과: 작업자가 오류를 리포트하고, 담당자가 “로그 어디 있나요?”를 묻고 회신을 기다리는 과정이 사라졌다. 덕분에 문제 인지부터 대응 착수까지의 시간이 단축되었고, 로그 수집·정리로 인한 운영 부담이 눈에 띄게 줄 것으로 기대된다.
  • 자동 생성된 티켓 패턴: 여러 건의 티켓이 동일한 사용자명-에러명 패턴으로 생성되며, 현재는 이 패턴을 이용해 중복 티켓 폭주를 방지하고 있다.

감지 → 시그니처 생성(중복 회피) → 수집/첨부 → 티켓 생성/기록
이 흐름이 안정적으로 돌아가면, 이후에는 세부적인 운영 정책 수립과 품질 개선에 집중할 수 있으로 기대된다.

Leave a comment