Skip to content

Commit a4812f2

Browse files
committed
fix: sentry-cli source map and release uploads
1 parent 8566d0a commit a4812f2

File tree

12 files changed

+357
-89
lines changed

12 files changed

+357
-89
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [0.1.7] - 2026-04-04
4+
5+
### Fixed
6+
- Source map and release uploads via sentry-cli
7+
- Upload chunk isolation and write safety
8+
39
## [0.1.6] - 2026-04-04
410

511
### Added

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "stackpit"
3-
version = "0.1.6"
3+
version = "0.1.7"
44
edition = "2021"
55
description = "Lightweight, self-hosted error tracking and event monitoring"
66
authors = ["Franz Geffke <mail@gofranz.com>"]

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,20 @@ Alert rules and digest schedules are managed via the web UI under **Alerts**, or
119119

120120
## Source Maps
121121

122-
stackpit supports source map uploads so JavaScript stack traces resolve to original source locations. Upload artifact bundles using the standard Sentry CLI:
122+
stackpit supports source map uploads so minified stack traces resolve to original source locations.
123+
124+
Generate a project API key in **Settings → Source Maps** for the project you want to upload to. Then configure `sentry-cli` or your bundler plugin with the ingest URL (the same host as your DSN):
123125

124126
```bash
125-
sentry-cli sourcemaps upload --org my-org --project my-project ./dist
127+
export SENTRY_URL=https://errors.example.com # ingest host
128+
export SENTRY_AUTH_TOKEN=spk_... # project API key
129+
export SENTRY_ORG=default # any value works
130+
export SENTRY_PROJECT=1 # project ID
131+
132+
sentry-cli sourcemaps upload ./dist
126133
```
127134

128-
The upload endpoints (`/api/0/organizations/{org}/chunk-upload/` and `.../artifactbundle/assemble/`) are Sentry-compatible. Source maps are matched by debug ID and applied automatically when rendering stack traces in the web UI.
135+
Bundler plugins (`@sentry/vite-plugin`, `@sentry/webpack-plugin`, etc.) accept the same environment variables, or you can pass them as options. Source maps are matched by debug ID and applied automatically when rendering stack traces in the web UI.
129136

130137
Stale upload chunks older than 24 hours are cleaned up by a background task.
131138

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
DROP TABLE IF EXISTS upload_chunks;
2+
3+
CREATE TABLE upload_chunks (
4+
checksum TEXT NOT NULL,
5+
project_id BIGINT NOT NULL,
6+
data BYTEA NOT NULL,
7+
created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT,
8+
PRIMARY KEY (checksum, project_id)
9+
);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
DROP TABLE IF EXISTS upload_chunks;
2+
3+
CREATE TABLE upload_chunks (
4+
checksum TEXT NOT NULL,
5+
project_id INTEGER NOT NULL,
6+
data BLOB NOT NULL,
7+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
8+
PRIMARY KEY (checksum, project_id)
9+
);

src/api/mod.rs

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,48 +14,93 @@ pub mod projects;
1414
pub mod releases;
1515
pub mod sourcemaps;
1616

17-
pub fn routes() -> Router<AppState> {
17+
/// Sentry-compatible API routes (releases, sourcemaps) that authenticate
18+
/// via per-project API keys. Served on the ingest port since sentry-cli
19+
/// sends requests to the DSN host (SENTRY_URL).
20+
pub fn sentry_api_routes() -> Router<AppState> {
1821
Router::new()
19-
.route("/api/v1/projects/", get(projects::list))
2022
.route(
21-
"/api/v1/projects/{project_id}/issues/",
22-
get(issues::list_for_project),
23+
"/api/0/projects/{org}/{project_slug}/",
24+
get(projects::sentry_get),
2325
)
2426
.route(
25-
"/api/v1/projects/{project_id}/events/",
26-
get(events::list_for_project),
27+
"/api/0/projects/{org}/{project_slug}",
28+
get(projects::sentry_get),
2729
)
2830
.route(
29-
"/api/v1/issues/{fingerprint}/",
30-
get(issues::get).put(issues::update_status),
31+
"/api/0/organizations/{org}/releases/",
32+
post(releases::create),
3133
)
3234
.route(
33-
"/api/v1/issues/{fingerprint}/events/",
34-
get(events::list_for_issue),
35+
"/api/0/organizations/{org}/releases",
36+
post(releases::create),
3537
)
3638
.route(
37-
"/api/v1/issues/{fingerprint}/events/latest/",
38-
get(events::latest_for_issue),
39+
"/api/0/projects/{org}/{project_slug}/releases/",
40+
post(releases::create_project_scoped),
3941
)
40-
.route("/api/v1/events/{event_id}/", get(events::get))
41-
// Sentry-compatible release API
4242
.route(
43-
"/api/0/organizations/{org}/releases/",
44-
post(releases::create),
43+
"/api/0/projects/{org}/{project_slug}/releases",
44+
post(releases::create_project_scoped),
4545
)
4646
.route(
4747
"/api/0/organizations/{org}/releases/{version}/",
4848
put(releases::update),
4949
)
50-
// Sentry-compatible sourcemap / artifact bundle API
50+
.route(
51+
"/api/0/organizations/{org}/releases/{version}",
52+
put(releases::update),
53+
)
54+
.route(
55+
"/api/0/projects/{org}/{project_slug}/releases/{version}/",
56+
put(releases::update_project_scoped),
57+
)
58+
.route(
59+
"/api/0/projects/{org}/{project_slug}/releases/{version}",
60+
put(releases::update_project_scoped),
61+
)
5162
.route(
5263
"/api/0/organizations/{org}/chunk-upload/",
5364
get(sourcemaps::chunk_upload_config).post(sourcemaps::chunk_upload),
5465
)
66+
.route(
67+
"/api/0/organizations/{org}/chunk-upload",
68+
get(sourcemaps::chunk_upload_config).post(sourcemaps::chunk_upload),
69+
)
5570
.route(
5671
"/api/0/organizations/{org}/artifactbundle/assemble/",
5772
post(sourcemaps::assemble),
5873
)
74+
.route(
75+
"/api/0/organizations/{org}/artifactbundle/assemble",
76+
post(sourcemaps::assemble),
77+
)
78+
}
79+
80+
pub fn routes() -> Router<AppState> {
81+
Router::new()
82+
.route("/api/v1/projects/", get(projects::list))
83+
.route(
84+
"/api/v1/projects/{project_id}/issues/",
85+
get(issues::list_for_project),
86+
)
87+
.route(
88+
"/api/v1/projects/{project_id}/events/",
89+
get(events::list_for_project),
90+
)
91+
.route(
92+
"/api/v1/issues/{fingerprint}/",
93+
get(issues::get).put(issues::update_status),
94+
)
95+
.route(
96+
"/api/v1/issues/{fingerprint}/events/",
97+
get(events::list_for_issue),
98+
)
99+
.route(
100+
"/api/v1/issues/{fingerprint}/events/latest/",
101+
get(events::latest_for_issue),
102+
)
103+
.route("/api/v1/events/{event_id}/", get(events::get))
59104
// Alert rules
60105
.route(
61106
"/api/v1/alerts/rules",

src/api/projects.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
use axum::extract::{Path, State};
2+
use axum::http::{HeaderMap, StatusCode};
13
use axum::response::IntoResponse;
4+
use axum::Json;
5+
use serde_json::json;
26

37
use crate::queries;
8+
use crate::server::AppState;
49

510
use super::json_or_500;
611
use crate::extractors::ApiReadPool;
@@ -9,3 +14,37 @@ use crate::extractors::ApiReadPool;
914
pub async fn list(ApiReadPool(pool): ApiReadPool) -> impl IntoResponse {
1015
json_or_500(queries::projects::list_projects(&pool, None, None, None).await)
1116
}
17+
18+
/// GET /api/0/projects/{org}/{project_id}/
19+
///
20+
/// Minimal project detail endpoint that sentry-cli calls to validate
21+
/// a project before creating releases or uploading sourcemaps.
22+
pub async fn sentry_get(
23+
State(state): State<AppState>,
24+
Path((_org, project_slug)): Path<(String, String)>,
25+
headers: HeaderMap,
26+
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
27+
let key_project_id = super::validate_api_key(&state.pool, &headers, "sourcemap").await?;
28+
29+
let project_id: u64 = project_slug
30+
.parse()
31+
.map_err(|_| super::api_error(StatusCode::NOT_FOUND, "project not found"))?;
32+
33+
if project_id != key_project_id {
34+
return Err(super::api_error(StatusCode::NOT_FOUND, "project not found"));
35+
}
36+
37+
let info = queries::projects::get_project_info(&state.pool, project_id)
38+
.await
39+
.map_err(super::internal_error)?;
40+
41+
match info {
42+
Some(info) => Ok(Json(json!({
43+
"id": project_id.to_string(),
44+
"slug": project_slug,
45+
"name": info.name.unwrap_or_else(|| format!("Project {project_id}")),
46+
"status": info.status.as_str(),
47+
}))),
48+
None => Err(super::api_error(StatusCode::NOT_FOUND, "project not found")),
49+
}
50+
}

src/api/releases.rs

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ use crate::extractors::ApiReadPool;
1212
#[derive(Deserialize)]
1313
pub struct CreateReleaseRequest {
1414
pub version: String,
15+
/// sentry-cli sends slugs as strings and numeric IDs as integers,
16+
/// so we accept arbitrary JSON values and parse them ourselves.
1517
#[serde(default)]
16-
pub projects: Vec<String>,
18+
pub projects: Vec<serde_json::Value>,
1719
}
1820

1921
#[derive(Deserialize)]
@@ -33,12 +35,30 @@ pub struct CommitRef {
3335
pub commit: String,
3436
}
3537

38+
/// POST /api/0/projects/{org}/{project_slug}/releases/
39+
pub async fn create_project_scoped(
40+
State(state): State<AppState>,
41+
Path((_org, _project)): Path<(String, String)>,
42+
headers: HeaderMap,
43+
Json(body): Json<CreateReleaseRequest>,
44+
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
45+
create_inner(state, headers, body).await
46+
}
47+
3648
/// POST /api/0/organizations/{org}/releases/
3749
pub async fn create(
3850
State(state): State<AppState>,
3951
Path(_org): Path<String>,
4052
headers: HeaderMap,
4153
Json(body): Json<CreateReleaseRequest>,
54+
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
55+
create_inner(state, headers, body).await
56+
}
57+
58+
async fn create_inner(
59+
state: AppState,
60+
headers: HeaderMap,
61+
body: CreateReleaseRequest,
4262
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
4363
let key_project_id = super::validate_api_key(&state.pool, &headers, "sourcemap").await?;
4464
if body.version.is_empty() {
@@ -52,13 +72,16 @@ pub async fn create(
5272
}
5373

5474
// Create release for each project in the list (must match the key's project)
55-
for project_str in &body.projects {
56-
let project_id: u64 = project_str.parse().map_err(|_| {
57-
api_error(
58-
StatusCode::BAD_REQUEST,
59-
&format!("invalid project id: {project_str}"),
60-
)
61-
})?;
75+
for project_val in &body.projects {
76+
let project_id: u64 = project_val
77+
.as_u64()
78+
.or_else(|| project_val.as_str().and_then(|s| s.parse().ok()))
79+
.ok_or_else(|| {
80+
api_error(
81+
StatusCode::BAD_REQUEST,
82+
&format!("invalid project id: {project_val}"),
83+
)
84+
})?;
6285

6386
if project_id != key_project_id {
6487
return Err(api_error(
@@ -87,13 +110,34 @@ pub async fn create(
87110
))
88111
}
89112

113+
/// PUT /api/0/projects/{org}/{project_slug}/releases/{version}/
114+
pub async fn update_project_scoped(
115+
State(state): State<AppState>,
116+
ApiReadPool(pool): ApiReadPool,
117+
Path((_org, _project, version)): Path<(String, String, String)>,
118+
headers: HeaderMap,
119+
Json(body): Json<UpdateReleaseRequest>,
120+
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
121+
update_inner(state, pool, version, headers, body).await
122+
}
123+
90124
/// PUT /api/0/organizations/{org}/releases/{version}/
91125
pub async fn update(
92126
State(state): State<AppState>,
93127
ApiReadPool(pool): ApiReadPool,
94128
Path((_org, version)): Path<(String, String)>,
95129
headers: HeaderMap,
96130
Json(body): Json<UpdateReleaseRequest>,
131+
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
132+
update_inner(state, pool, version, headers, body).await
133+
}
134+
135+
async fn update_inner(
136+
state: AppState,
137+
pool: crate::db::DbPool,
138+
version: String,
139+
headers: HeaderMap,
140+
body: UpdateReleaseRequest,
97141
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
98142
let key_project_id = super::validate_api_key(&state.pool, &headers, "sourcemap").await?;
99143
// Process commit refs — take the first ref's commit as the release commit

0 commit comments

Comments
 (0)