feat: xuqm-log-service 新建 — 日志/错误追踪服务

Agent 5 — xuqm-log-service:
- Spring Boot 3.x + MySQL + Redis
- 5 张表:log_issues、log_issue_events、log_events、log_sourcemaps、log_webhooks
- SDK 入库接口:POST /log/v1/issues/batch(指纹去重)、POST /log/v1/events/batch
- 查询接口:issues 列表/详情、高频/高危排行、事件流水、漏斗分析、概览统计
- SourceMap 上传:POST /log/v1/sourcemaps/upload
- Webhook CRUD + Redis SETNX 冷却逻辑
- Flyway 数据库迁移
这个提交包含在:
XuqmGroup 2026-06-16 12:10:58 +08:00
父节点 336ce72c7a
当前提交 936664c9cd
共有 20 个文件被更改,包括 816 次插入0 次删除

75
xuqm-log-service/pom.xml 普通文件
查看文件

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.xuqm</groupId>
<artifactId>xuqmgroup-server-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>xuqm-log-service</artifactId>
<name>xuqm-log-service</name>
<description>Log collection, dedup, symbolication, webhook notification &amp; funnel analytics</description>
<dependencies>
<dependency>
<groupId>com.xuqm</groupId>
<artifactId>common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

查看文件

@ -0,0 +1,15 @@
package com.xuqm.log;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@ComponentScan(basePackages = {"com.xuqm.log", "com.xuqm.common"})
@EnableAsync
public class LogServiceApplication {
public static void main(String[] args) {
SpringApplication.run(LogServiceApplication.class, args);
}
}

查看文件

@ -0,0 +1,21 @@
package com.xuqm.log.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record EventBatchRequest(
@JsonProperty("events") @NotEmpty List<EventItem> events
) {
public record EventItem(
@NotBlank @JsonProperty("appKey") String appKey,
@NotBlank String name,
@JsonProperty("userId") String userId,
@JsonProperty("sessionId") String sessionId,
String properties,
String platform,
@JsonProperty("appVersion") String appVersion,
@JsonProperty("timestamp") long timestamp
) {}
}

查看文件

@ -0,0 +1,23 @@
package com.xuqm.log.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record IssueBatchRequest(
@JsonProperty("events") @NotEmpty List<IssueEventItem> events
) {
public record IssueEventItem(
@NotBlank String type,
String message,
String stack,
@NotBlank String fingerprint,
@JsonProperty("timestamp") long timestamp,
@JsonProperty("userId") String userId,
@JsonProperty("sessionId") String sessionId,
@NotBlank @JsonProperty("appKey") String appKey,
String platform,
@JsonProperty("appVersion") String appVersion
) {}
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.log.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
public record IssueEventResponse(
Long id,
@JsonProperty("issueId") Long issueId,
@JsonProperty("appKey") String appKey,
@JsonProperty("userId") String userId,
@JsonProperty("sessionId") String sessionId,
String message,
String stack,
@JsonProperty("stackSymbolicated") String stackSymbolicated,
String metadata,
String platform,
@JsonProperty("appVersion") String appVersion,
@JsonProperty("createdAt") LocalDateTime createdAt
) {}

查看文件

@ -0,0 +1,20 @@
package com.xuqm.log.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
public record IssueResponse(
Long id,
@JsonProperty("appKey") String appKey,
String fingerprint,
String type,
String title,
@JsonProperty("firstSeenAt") LocalDateTime firstSeenAt,
@JsonProperty("lastSeenAt") LocalDateTime lastSeenAt,
int count,
@JsonProperty("isResolved") boolean isResolved,
String platform,
@JsonProperty("appVersion") String appVersion,
List<IssueEventResponse> events
) {}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.log.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public record WebhookRequest(
@NotBlank @JsonProperty("appKey") String appKey,
@NotBlank String url,
@NotNull List<String> events,
@JsonProperty("cooldownSec") int cooldownSec
) {}

查看文件

@ -0,0 +1,15 @@
package com.xuqm.log.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime;
import java.util.List;
public record WebhookResponse(
Long id,
@JsonProperty("appKey") String appKey,
String url,
List<String> events,
@JsonProperty("cooldownSec") int cooldownSec,
boolean enabled,
@JsonProperty("createdAt") LocalDateTime createdAt
) {}

查看文件

@ -0,0 +1,64 @@
package com.xuqm.log.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "log_events")
public class LogEventEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 64)
private String appKey;
@Column(nullable = false, length = 256)
private String name;
@Column(length = 128)
private String userId;
@Column(length = 128)
private String sessionId;
@Column(columnDefinition = "JSON")
private String properties;
@Column(length = 16)
private String platform;
@Column(length = 32)
private String appVersion;
@Column(nullable = false)
private LocalDateTime createdAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getProperties() { return properties; }
public void setProperties(String properties) { this.properties = properties; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,76 @@
package com.xuqm.log.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "log_issues")
public class LogIssueEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 64)
private String appKey;
@Column(nullable = false, length = 64)
private String fingerprint;
@Column(nullable = false, length = 32)
private String type;
@Column(nullable = false, length = 500)
private String title;
@Column(nullable = false)
private LocalDateTime firstSeenAt;
@Column(nullable = false)
private LocalDateTime lastSeenAt;
@Column(nullable = false)
private int count = 1;
@Column(nullable = false)
private boolean isResolved = false;
@Column(length = 16)
private String platform;
@Column(length = 32)
private String appVersion;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getFingerprint() { return fingerprint; }
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public LocalDateTime getFirstSeenAt() { return firstSeenAt; }
public void setFirstSeenAt(LocalDateTime firstSeenAt) { this.firstSeenAt = firstSeenAt; }
public LocalDateTime getLastSeenAt() { return lastSeenAt; }
public void setLastSeenAt(LocalDateTime lastSeenAt) { this.lastSeenAt = lastSeenAt; }
public int getCount() { return count; }
public void setCount(int count) { this.count = count; }
public boolean isResolved() { return isResolved; }
public void setResolved(boolean resolved) { isResolved = resolved; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
}

查看文件

@ -0,0 +1,82 @@
package com.xuqm.log.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "log_issue_events")
public class LogIssueEventEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long issueId;
@Column(nullable = false, length = 64)
private String appKey;
@Column(length = 128)
private String userId;
@Column(length = 128)
private String sessionId;
@Column(columnDefinition = "TEXT")
private String message;
@Column(columnDefinition = "LONGTEXT")
private String stack;
@Column(name = "stack_symbolicated", columnDefinition = "LONGTEXT")
private String stackSymbolicated;
@Column(columnDefinition = "JSON")
private String metadata;
@Column(length = 16)
private String platform;
@Column(length = 32)
private String appVersion;
@Column(nullable = false)
private LocalDateTime createdAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getIssueId() { return issueId; }
public void setIssueId(Long issueId) { this.issueId = issueId; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getStack() { return stack; }
public void setStack(String stack) { this.stack = stack; }
public String getStackSymbolicated() { return stackSymbolicated; }
public void setStackSymbolicated(String stackSymbolicated) { this.stackSymbolicated = stackSymbolicated; }
public String getMetadata() { return metadata; }
public void setMetadata(String metadata) { this.metadata = metadata; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,52 @@
package com.xuqm.log.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "log_sourcemaps")
public class LogSourcemapEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 64)
private String appKey;
@Column(nullable = false, length = 16)
private String platform;
@Column(nullable = false, length = 32)
private String appVersion;
@Column(nullable = false, length = 128)
private String bundleName = "index";
@Column(nullable = false, length = 512)
private String storageKey;
@Column(nullable = false)
private LocalDateTime uploadedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public String getBundleName() { return bundleName; }
public void setBundleName(String bundleName) { this.bundleName = bundleName; }
public String getStorageKey() { return storageKey; }
public void setStorageKey(String storageKey) { this.storageKey = storageKey; }
public LocalDateTime getUploadedAt() { return uploadedAt; }
public void setUploadedAt(LocalDateTime uploadedAt) { this.uploadedAt = uploadedAt; }
}

查看文件

@ -0,0 +1,52 @@
package com.xuqm.log.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "log_webhooks")
public class LogWebhookEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 64)
private String appKey;
@Column(nullable = false, length = 1024)
private String url;
@Column(nullable = false, columnDefinition = "JSON")
private String events;
@Column(nullable = false)
private int cooldownSec = 3600;
@Column(nullable = false)
private boolean enabled = true;
@Column(nullable = false)
private LocalDateTime createdAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public String getEvents() { return events; }
public void setEvents(String events) { this.events = events; }
public int getCooldownSec() { return cooldownSec; }
public void setCooldownSec(int cooldownSec) { this.cooldownSec = cooldownSec; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -0,0 +1,44 @@
package com.xuqm.log.repository;
import com.xuqm.log.entity.LogEventEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
public interface LogEventRepository extends JpaRepository<LogEventEntity, Long> {
Page<LogEventEntity> findByAppKeyAndNameAndUserIdAndCreatedAtBetween(
String appKey, String name, String userId,
LocalDateTime from, LocalDateTime to,
Pageable pageable);
Page<LogEventEntity> findByAppKeyAndNameAndCreatedAtBetween(
String appKey, String name,
LocalDateTime from, LocalDateTime to,
Pageable pageable);
Page<LogEventEntity> findByAppKeyAndCreatedAtBetween(
String appKey, LocalDateTime from, LocalDateTime to, Pageable pageable);
@Query(value = "SELECT e.session_id, e.name, MIN(e.created_at) AS first_time " +
"FROM log_events e " +
"WHERE e.app_key = :appKey AND e.name IN :steps AND e.created_at BETWEEN :from AND :to " +
"GROUP BY e.session_id, e.name",
nativeQuery = true)
List<Object[]> findFunnelData(@Param("appKey") String appKey,
@Param("steps") List<String> steps,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
long countByAppKeyAndNameAndCreatedAtBetween(String appKey, String name, LocalDateTime from, LocalDateTime to);
@Query("SELECT DISTINCT e.name FROM LogEventEntity e WHERE e.appKey = :appKey")
List<String> findDistinctEventNames(@Param("appKey") String appKey);
void deleteByAppKey(String appKey);
}

查看文件

@ -0,0 +1,21 @@
package com.xuqm.log.repository;
import com.xuqm.log.entity.LogIssueEventEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface LogIssueEventRepository extends JpaRepository<LogIssueEventEntity, Long> {
List<LogIssueEventEntity> findTop20ByIssueIdOrderByCreatedAtDesc(Long issueId);
Page<LogIssueEventEntity> findByAppKeyAndCreatedAtBetween(
String appKey, LocalDateTime from, LocalDateTime to, Pageable pageable);
void deleteByIssueId(Long issueId);
void deleteByAppKey(String appKey);
}

查看文件

@ -0,0 +1,58 @@
package com.xuqm.log.repository;
import com.xuqm.log.entity.LogIssueEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface LogIssueRepository extends JpaRepository<LogIssueEntity, Long> {
Optional<LogIssueEntity> findByAppKeyAndFingerprint(String appKey, String fingerprint);
@Modifying
@Query("UPDATE LogIssueEntity i SET i.count = i.count + 1, i.lastSeenAt = :now WHERE i.appKey = :appKey AND i.fingerprint = :fingerprint")
int incrementCount(@Param("appKey") String appKey, @Param("fingerprint") String fingerprint, @Param("now") LocalDateTime now);
Page<LogIssueEntity> findByAppKeyAndTypeAndPlatformAndLastSeenAtBetween(
String appKey, String type, String platform,
LocalDateTime from, LocalDateTime to,
Pageable pageable);
Page<LogIssueEntity> findByAppKeyAndLastSeenAtBetween(
String appKey, LocalDateTime from, LocalDateTime to, Pageable pageable);
Page<LogIssueEntity> findByAppKey(String appKey, Pageable pageable);
@Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey AND i.lastSeenAt BETWEEN :from AND :to ORDER BY i.count DESC")
List<LogIssueEntity> findTopByFrequency(@Param("appKey") String appKey,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
@Query(value = "SELECT i.*, " +
"(i.count * COALESCE((SELECT COUNT(DISTINCT e.user_id) FROM log_issue_events e WHERE e.issue_id = i.id AND e.user_id IS NOT NULL), 1) * " +
"CASE i.type WHEN 'native_crash' THEN 10 WHEN 'js_error' THEN 5 WHEN 'api_error' THEN 3 ELSE 1 END) AS risk_score " +
"FROM log_issues i WHERE i.app_key = :appKey AND i.last_seen_at BETWEEN :from AND :to " +
"ORDER BY risk_score DESC",
nativeQuery = true)
List<LogIssueEntity> findTopByRisk(@Param("appKey") String appKey,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
long countByAppKeyAndFirstSeenAtBetween(String appKey, LocalDateTime from, LocalDateTime to);
long countByAppKeyAndFirstSeenAtAfter(String appKey, LocalDateTime since);
@Query("SELECT DISTINCT i.platform FROM LogIssueEntity i WHERE i.appKey = :appKey")
List<String> findDistinctPlatforms(@Param("appKey") String appKey);
void deleteByAppKey(String appKey);
}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.log.repository;
import com.xuqm.log.entity.LogSourcemapEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface LogSourcemapRepository extends JpaRepository<LogSourcemapEntity, Long> {
Optional<LogSourcemapEntity> findByAppKeyAndPlatformAndAppVersionAndBundleName(
String appKey, String platform, String appVersion, String bundleName);
void deleteByAppKey(String appKey);
}

查看文件

@ -0,0 +1,15 @@
package com.xuqm.log.repository;
import com.xuqm.log.entity.LogWebhookEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface LogWebhookRepository extends JpaRepository<LogWebhookEntity, Long> {
List<LogWebhookEntity> findByAppKeyAndEnabledTrue(String appKey);
List<LogWebhookEntity> findByAppKey(String appKey);
void deleteByAppKey(String appKey);
}

查看文件

@ -0,0 +1,68 @@
server:
port: 9006
spring:
application:
name: xuqm-log-service
datasource:
url: jdbc:mysql://39.107.53.187:3306/xuqm_log?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: xuqm
password: Xuqm@2026
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
minimum-idle: 5
maximum-pool-size: 20
connection-timeout: 30000
idle-timeout: 300000
max-lifetime: 900000
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true
data:
redis:
host: redisdev.xuqinmin.com
port: 6379
password: xuqinmin1022
database: 2
timeout: 10s
lettuce:
pool:
min-idle: 0
max-idle: 8
max-active: 8
jackson:
time-zone: UTC
serialization:
write-dates-as-timestamps: false
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
locations: classpath:db/migration
table: flyway_history_log
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
log-service:
sourcemap:
storage-dir: ${LOG_SOURCEMAP_DIR:/data/log-service/sourcemaps}
webhook:
connect-timeout-ms: 3000
read-timeout-ms: 5000
logging:
level:
com.xuqm: DEBUG
management:
endpoints:
web:
exposure:
include: health,info

查看文件

@ -0,0 +1,69 @@
CREATE TABLE log_issues (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
fingerprint CHAR(64) NOT NULL,
type VARCHAR(32) NOT NULL,
title VARCHAR(500) NOT NULL,
first_seen_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
count INT NOT NULL DEFAULT 1,
is_resolved TINYINT NOT NULL DEFAULT 0,
platform VARCHAR(16),
app_version VARCHAR(32),
UNIQUE KEY uk_app_fp (app_key, fingerprint),
INDEX idx_app_last (app_key, last_seen_at),
INDEX idx_app_type (app_key, type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE log_issue_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
issue_id BIGINT NOT NULL,
app_key VARCHAR(64) NOT NULL,
user_id VARCHAR(128),
session_id VARCHAR(128),
message TEXT,
stack LONGTEXT,
stack_symbolicated LONGTEXT,
metadata JSON,
platform VARCHAR(16),
app_version VARCHAR(32),
created_at DATETIME NOT NULL,
INDEX idx_issue (issue_id),
INDEX idx_app_time (app_key, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE log_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
user_id VARCHAR(128),
session_id VARCHAR(128),
properties JSON,
platform VARCHAR(16),
app_version VARCHAR(32),
created_at DATETIME NOT NULL,
INDEX idx_app_name (app_key, name),
INDEX idx_app_time (app_key, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE log_sourcemaps (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
platform VARCHAR(16) NOT NULL,
app_version VARCHAR(32) NOT NULL,
bundle_name VARCHAR(128) NOT NULL DEFAULT 'index',
storage_key VARCHAR(512) NOT NULL,
uploaded_at DATETIME NOT NULL,
UNIQUE KEY uk_map (app_key, platform, app_version, bundle_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE log_webhooks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
url VARCHAR(1024) NOT NULL,
events JSON NOT NULL,
cooldown_sec INT NOT NULL DEFAULT 3600,
enabled TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
INDEX idx_app (app_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;