ES /docs

{}

RCA: Empty error object logged during floorplan tile upload

Overview#

What Happened#

2026-05-08 05:28:12 UTC에 cupixworks-any-floorplan-agent 서비스에서 floorplan 86241의 tile upload credentials 요청 시 API가 403 "Floorplan not found"를 반환했다. 에이전트가 약 3분간 tiling 작업을 수행하는 동안 해당 floorplan이 삭제/접근 불가 상태가 되었으며, 에러 객체가 {} (빈 객체)로 직렬화되어 로깅되었다.

Quick Facts#

Field Value
exception.message {} (empty serialized Error object)
top_frame floorplan-service.ts:107
runtime Node.js (TypeScript agent)
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
hoban (team 1163) 1 Floorplan 86241의 tile 업로드 실패, 결과물 미생성

Timeline#

  1. 05:25:30Z — 에이전트가 floorplan 86241 PDF 변환 시작 (source: 35029.pdf, frame 0)
  2. 05:27:28Z — tileFloorplan 시작
  3. 05:28:12.864Z — tiling 완료, tile 디렉토리 저장 완료
  4. 05:28:12.904Z — tile_upload_credentials API 요청 → 403 Floorplan not found (warn 로그)
  5. 05:28:12.905Z — 에러 {} 로깅 (Error 객체 직렬화 실패)
  6. 05:28:12.905Z — cleanUpAnythingRelatedModel 실행
  7. 05:28:13Z — SQS 메시지 삭제, 처리 종료

Error Log#

Datadog Logs

text
{}

Impact#

  • Service: cupixworks-any-floorplan-agent
  • 발생 횟수: 1
  • 최초 발생: 2026-05-08T05:28:12.905Z
  • 최근 발생: 2026-05-08T05:28:12.905Z

동일 세션에서 floorplan 86223, 86230, 86242, 86243도 같은 "Floorplan not found" 오류를 경험했다. 동일 사용자(daniel.kim@cupix.com, team hoban)의 일괄 작업으로 추정되며, 처리 중 floorplan이 삭제된 것이 원인이다.

Root Cause Summary#

Floorplan 86241이 에이전트의 tile 처리 도중(약 3분 소요) 삭제되거나 접근 불가 상태로 변경되었다. tiling 완료 후 createTileUploadCredentials API를 호출했을 때 Tesla API가 HTTP 403 ARG10002 Floorplan not found를 반환했다. 이 에러가 floorplan-service.ts:107의 catch 블록에서 logger.error('FloorplanService::run | error', err)로 로깅될 때, winston의 format chain에서 Error 객체의 non-enumerable 속성(message, stack)이 JSON 직렬화 과정에서 누락되어 Datadog에 {} 로만 기록되었다. base/src/util/utils.ts:10에 이 문제를 해결하는 stringifyError 유틸이 이미 존재하지만 해당 코드 경로에서는 사용되지 않고 있다.

Technical Analysis#

Code Path#

  • Entry point: base-service.ts:107-108runByMessages() catch 블록에서 handlingMessageErrors(error) 호출 또는 FloorplanService::run 내부 catch
  • Tile processing: floorplan-service.ts:104await this.tileFloorplan(cpFloorplan) 성공 (05:28:12.864Z)
  • API call: floorplan-service.ts:323await this.cupixApi.floorplan.createTileUploadCredentials(cpFloorplan.id) 실패 (403)
  • Error wrap: floorplan-service.ts:325 — 에러를 새 Error로 래핑하여 re-throw
  • Failure point: floorplan-service.ts:107 — catch 블록에서 에러 로깅 시 {} 출력

1. 메인 실행 흐름 — tiling 성공 후 uploadTile에서 실패:

packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:98-108typescript
try {
    const exifTags = await this.getExifTags(cpFloorplan.localFilePath as string);
    await this.updateFloorplanExif(targetId, exifTags);

    const resolution = await this.getImageResolution(cpFloorplan.localFilePath);
    await this.updateFloorplanResolution(targetId, resolution);
    await this.tileFloorplan(cpFloorplan);
    await this.uploadTile(cpFloorplan);
} catch (err) {
    logger.error('FloorplanService::run | error', err);
}

line 105의 uploadTile에서 에러가 throw되면 line 107의 catch 블록에서 로깅한다. 이 catch는 에러를 삼키므로(handlingMessageErrors로 전파되지 않음), cleanup과 SQS 메시지 삭제가 정상 진행된다.

2. uploadTile — tile_upload_credentials API 호출 실패:

packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:321-326typescript
let s3Credentials: TESLA.UploadCredentials;
try {
    s3Credentials = await this.cupixApi.floorplan.createTileUploadCredentials(cpFloorplan.id);
} catch (error) {
    throw new Error(`FloorplanService::uploadTile - createTileUploadCredentials error - ${error}`);
}

createTileUploadCredentials가 HTTP 403을 받으면 TESLA API 클라이언트가 reject한다. catch에서 new Error(...) 로 래핑하여 re-throw한다. ${error}는 원본 에러의 .toString()을 호출하므로 "[object Object]"가 포함된다.

3. FloorplanApiModule — 생성된 API 호출:

packages/api/src/api/floorplan.api.ts:10-14typescript
createTileUploadCredentials = async (id: number): Promise<TESLA.UploadCredentials> => {
    const api = await this.api();
    const res = await api.createTileUploadCredentials(id, Fields.UploadCredentialFields);
    return unwrapResult(res);
};

this.api()getApi를 호출하며, 내부에서 auth.checkToken()으로 토큰 유효성을 확인한 후 TESLA API 인스턴스를 반환한다.

4. CupixAuth::handleError — warn 로그 생성:

packages/api/src/authentication/cupix-auth.ts:42-57typescript
handleError = (ec: any): any => {
    const response = ec && CPUtils.isJsonString(ec) ? JSON.parse(ec) : ec.response;
    if (response != undefined) {
        const statusCode = response.status || response.statusCode;
        const requestUriHref = response.config?.url || response.request?.uri?.href;
        const bodyResult = response.data?.result || response.body?.result;
        if (statusCode != undefined && this.setErrorCode != undefined)
            this.setErrorCode(ErrorCode.Tesla.statusCode(statusCode.toString()));

        logger.warn('CupixAuth::handleError | Response statusCode: %d, requestUriHref: %s, body.result: %s',
            statusCode, requestUriHref, JSON.stringify(bodyResult));
    } else {
        logger.warn('CupixAuth::handleError | Undefined response: %s',
            JSON.stringify(ec, Object.getOwnPropertyNames(ec)));
    }
    return ec;
};

handleError는 에러 객체를 warn으로 로깅한 후 원본을 그대로 반환한다. 이 함수에서는 Object.getOwnPropertyNames를 사용하여 올바르게 직렬화하고 있다.

5. Logger JSON 포맷 — {} 직렬화 발생 지점:

packages/utils/src/cplogger.ts:160-182typescript
new DailyRotateFile({
    level: 'silly',
    dirname: Environment.CPX_WORKSPACE_PATH,
    filename: `${label}-json-%DATE%.log`,
    format: winston.format.combine(
        winston.format.errors({ stack: true }),
        winston.format.splat(),
        errorSafeFormat(),
        // ...
        winston.format.printf((info) => {
            const { level, message, label, timestamp, stack, ...rest } = info;
            const cleanRest = filterNumericKeys(rest);
            return JSON.stringify({
                timestamp, level, label, message,
                ...(stack ? { stack } : {}),
                ...cleanRest
            });
        })
    )
})

logger.error('FloorplanService::run | error', err) 호출 시:

  • format.splat()이 format string에 %s placeholder가 없으므로 err를 message에 interpolate하지 않음
  • errorSafeFormat()의 splat 배열 처리(line 22-33)는 err instanceof Error 체크로 Error를 {name, message, stack}으로 변환하지만, 이 변환된 값이 최종 JSON 출력의 message 필드에 반영되지 않음
  • Datadog는 JSON 로그의 message 필드를 인덱싱하므로, 변환된 error 정보가 splat에만 남고 message 필드에는 포함되지 않는 edge case 발생

6. 이미 존재하는 올바른 유틸리티 함수 (미사용):

packages/base/src/util/utils.ts:10-12typescript
export const stringifyError = (error: any): string => {
    return JSON.stringify(error, Object.getOwnPropertyNames(error));
};

stringifyError가 Error의 non-enumerable 속성까지 포함하여 직렬화하지만, floorplan-service.ts에서는 사용되지 않고 있다.

Log Evidence#

사용된 Datadog 쿼리:

text
service:cupixworks-any-floorplan-agent status:error @environment:production
Time range: 2026-05-08T04:28:12Z to 2026-05-08T06:58:12Z
text
service:cupixworks-any-floorplan-agent @environment:production
Time range: 2026-05-08T05:00:00Z to 2026-05-08T05:40:00Z

타겟 에러 로그 전체 속성:

json
{
  "id": "AwAAAZ4GDslpoODD9QAAABhBWjRHRHRYM0FBQmJ6MUtUOHV3cy13QUMAAAAkMDE5ZTA2MTQtMTc1My00YmI2LTgwM2QtZGIwN2Q1MWI0ZjUyAAAzig",
  "service": "cupixworks-any-floorplan-agent",
  "timestamp": "2026-05-08T05:28:12.905Z",
  "status": "error",
  "message": "{}",
  "host": "ip-10-1-109-82.us-west-2.compute.internal",
  "meta.floorplan.id": 86241,
  "meta.user.email": "daniel.kim@cupix.com",
  "meta.team.domain": "hoban",
  "meta.team.id": 1163,
  "meta.session.id": "6db621d50c9bf84e1ca7cc6d9df5366e103f3ae4"
}

직전 warn 로그 (1ms 전):

text
2026-05-08T05:28:12.904Z [agent] [warn] CupixAuth::handleError | Response statusCode: 403, requestUriHref: http://api-tesla.cupix.internal/api/v1/floorplans/86241/tile_upload_credentials?fields[0]=aws_access_key_id&fields[1]=aws_secret_access_key&fields[2]=aws_session_token&fields[3]=bucket_region&fields[4]=bucket_name&fields[5]=basepath&fields[6]=acl&fields[7]=expires_at&fields[8]=endpoint, body.result: {"code":"ARG10002","type":"Cupix::Errors::NotFound","reason":"Floorplan not found","message":"Floorplan not found"}

실행 흐름 전체 (host ip-10-1-109-82, floorplan 86241):

text
05:25:30.341 [info] FloorplanService::translateFloorplan | source: /tmp/workspace/86241/source/35029.pdf, frame: 0, output: /tmp/workspace/86241/86241.png, width: 32768, height: 32768, dpi: 300, LOD: 7
05:27:28.772 [info] FloorplanService::tileFloorplan | begin
05:28:12.864 [info] FloorplanService::tileFloorplan | tiling done and now save to '/tmp/workspace/86241/tile'
05:28:12.904 [warn] CupixAuth::handleError | Response statusCode: 403, ...Floorplan not found
05:28:12.905 [error] {}
05:28:12.905 [info] BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/86241
05:28:13.412 [info] AwsQueueManager::deleteMessage | begin - queue url: https://sqs.us-west-2.amazonaws.com/002596530511/cupix-tesla-floorplan-agent-production
05:28:13.439 [info] AwsQueueManager::deleteMessage | end - message id: 80b6cda2-6255-471e-a775-99ae10e25f49

동일 세션 비교 — floorplan 86243 (정상 직렬화됨, handlingMessageErrors 경로):

text
05:27:25.851 [error] BaseService::handlingMessageErrors | Error and message object - {"code":"AGT1602","error":{"statusCode":403,"requestUriHref":"http://api-tesla.cupix.internal/api/v1/floorplans/86243/check_uploading?...","bodyResult":{"code":"ARG10002","type":"Cupix::Errors::NotFound","reason":"Floorplan not found","message":"Floorplan not found"},"modelId":86243},"sqsMessage":{"MessageId":"0e69cf87-5dbf-43ec-a842-60b40e2f688f","Attributes":{"ApproximateReceiveCount":"1"}}}

Floorplan 86243은 check_uploading 단계(tiling 전)에서 실패하여 BaseService::handlingMessageErrors 경로로 진입했고, 에러가 getApiErrorToDeleteMessage에 의해 plain object로 변환되어 정상 직렬화되었다. 반면 86241은 tiling 성공 후 uploadTile 내부에서 실패하여 FloorplanService::run의 로컬 catch(line 106-108)에서만 처리되어 {}로 직렬화되었다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Floorplan이 처리 중 삭제되어 tile_upload_credentials 호출 시 403 반환 warn 로그에 ARG10002 Floorplan not found 명시, 동일 세션 5개 floorplan(86223, 86230, 86241, 86242, 86243) 동시 실패. 모두 같은 사용자/team Confirmed
H2 Error 객체의 JSON 직렬화 결함으로 {} 출력 JS JSON.stringify(new Error(...))"{}"는 well-known behavior. base/src/util/utils.ts:10에 이를 해결하는 stringifyError 유틸 존재하지만 미사용. floorplan 86243과 비교 시 코드 경로 차이로 직렬화 결과 다름 Confirmed
H3 인증 토큰 만료로 403 발생 403 status code 에러 코드가 ARG10002 (NotFound)이며 AUTH 계열 코드가 아님. tiling 전 API 호출(floorplan get, update 등)은 성공. checkToken이 getApi에서 호출되어 통과함 Rejected
H4 네트워크 일시 오류 또는 timeout 응답 body에 명확한 JSON Floorplan not found 메시지 포함. 403은 서버가 정상 응답한 것. errno/syscall 에러 없음 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • floorplan-service.ts:107logger.error('FloorplanService::run | error', err) 에서 Error 정보가 정상 출력되도록 수정. 가장 간단한 방법은 format placeholder를 추가하는 것: logger.error('FloorplanService::run | error: %s', err instanceof Error ? err.message : JSON.stringify(err)). 또는 이미 존재하는 stringifyError(err) 사용.
  • floorplan-service.ts:325${error} 대신 error instanceof Error ? error.message : String(error) 사용하여 "[object Object]" 방지.

단기 개선 (1주 이내)#

  • agents 모노레포 전체에서 JSON.stringify(err) 패턴을 검색하여 stringifyError(err) 또는 Error 직접 전달로 마이그레이션 (floorplan-service.ts:268, :273, :303 포함).
  • 에러 심각도 재평가: 외부 요인으로 리소스가 삭제된 경우 error가 아닌 warn으로 다운그레이드. 에이전트 자체 결함이 아닌 운영 시나리오.
  • Floorplan 삭제 시 처리 중인 SQS 메시지를 graceful하게 처리하는 로직 검토.

장기 개선 (재발 방지)#

  • ESLint 규칙 추가: JSON.stringify(err) 패턴 (Error를 직접 stringify하는 경우) 경고.
  • cplogger.tserrorSafeFormat이 format string 없이 Error를 두 번째 인자로 전달하는 패턴(logger.error('prefix', err))에서도 message 필드에 에러 정보가 포함되도록 개선.
  • API에서 floorplan 삭제 시 관련 SQS 메시지를 purge하는 lifecycle hook 구현.

Monitoring#

  • 빈 객체 에러 감지:
text
service:cupixworks-any-floorplan-agent status:error @message:"{}" @environment:production
  • Floorplan not found 빈도:
text
service:cupixworks-any-floorplan-agent "Floorplan not found" @environment:production

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial — 에러 로깅 개선은 1-2줄 수정. Floorplan 삭제 race condition은 운영 시나리오로 코드 버그가 아니며, 로깅 개선만으로도 디버깅 효율이 크게 향상됨.