From df65330435f7bf59e5d4b95beb1befcc0a440e91 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Tue, 6 May 2025 19:13:38 +0300 Subject: [PATCH] appview: instrument all the things --- appview/db/issues.go | 37 +- appview/db/profile.go | 32 +- appview/db/pulls.go | 136 ++++-- appview/db/repos.go | 126 +++++- appview/db/star.go | 9 +- appview/db/timeline.go | 31 +- appview/state/artifact.go | 2 +- appview/state/middleware.go | 44 +- appview/state/profile.go | 38 +- appview/state/pull.go | 758 ++++++++++++++++++++++++++++------ appview/state/repo.go | 794 +++++++++++++++++++++++++++++++----- appview/state/repo_util.go | 30 +- appview/state/state.go | 56 ++- telemetry/telemetry.go | 18 +- 14 files changed, 1801 insertions(+), 310 deletions(-) diff --git a/appview/db/issues.go b/appview/db/issues.go index fe5b441..4799feb 100644 --- a/appview/db/issues.go +++ b/appview/db/issues.go @@ -1,10 +1,13 @@ package db import ( + "context" "database/sql" "time" "github.com/bluesky-social/indigo/atproto/syntax" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" "tangled.sh/tangled.sh/core/appview/pagination" ) @@ -103,14 +106,25 @@ func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error return ownerDid, err } -func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { +func GetIssues(ctx context.Context, e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetIssues") + defer span.End() + + span.SetAttributes( + attribute.String("repo_at", repoAt.String()), + attribute.Bool("is_open", isOpen), + attribute.Int("page.offset", page.Offset), + attribute.Int("page.limit", page.Limit), + ) + var issues []Issue openValue := 0 if isOpen { openValue = 1 } - rows, err := e.Query( + rows, err := e.QueryContext( + ctx, ` with numbered_issue as ( select @@ -139,12 +153,13 @@ func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) body, open, comment_count - from + from numbered_issue - where + where row_num between ? and ?`, repoAt, openValue, page.Offset+1, page.Offset+page.Limit) if err != nil { + span.RecordError(err) return nil, err } defer rows.Close() @@ -155,11 +170,13 @@ func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) var metadata IssueMetadata err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) if err != nil { + span.RecordError(err) return nil, err } createdTime, err := time.Parse(time.RFC3339, createdAt) if err != nil { + span.RecordError(err) return nil, err } issue.Created = createdTime @@ -169,9 +186,11 @@ func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) } if err := rows.Err(); err != nil { + span.RecordError(err) return nil, err } + span.SetAttributes(attribute.Int("issues.count", len(issues))) return issues, nil } @@ -256,7 +275,10 @@ func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, return issues, nil } -func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { +func GetIssue(ctx context.Context, e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetIssue") + defer span.End() + query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` row := e.QueryRow(query, repoAt, issueId) @@ -276,7 +298,10 @@ func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { return &issue, nil } -func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { +func GetIssueWithComments(ctx context.Context, e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetIssueWithComments") + defer span.End() + query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` row := e.QueryRow(query, repoAt, issueId) diff --git a/appview/db/profile.go b/appview/db/profile.go index 3e9bd5a..a8c4ea6 100644 --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -1,8 +1,13 @@ package db import ( + "context" "fmt" "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) type RepoEvent struct { @@ -83,7 +88,14 @@ type PullEventStats struct { const TimeframeMonths = 7 -func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { +func MakeProfileTimeline(ctx context.Context, e Execer, forDid string) (*ProfileTimeline, error) { + span := trace.SpanFromContext(ctx) + defer span.End() + + span.SetAttributes( + attribute.String("forDid", forDid), + ) + timeline := ProfileTimeline{ ByMonth: make([]ByMonth, TimeframeMonths), } @@ -92,9 +104,13 @@ func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "error getting pulls by owner did") return nil, fmt.Errorf("error getting pulls by owner did: %w", err) } + span.SetAttributes(attribute.Int("pulls.count", len(pulls))) + // group pulls by month for _, pull := range pulls { pullMonth := pull.Created.Month() @@ -112,9 +128,13 @@ func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "error getting issues by owner did") return nil, fmt.Errorf("error getting issues by owner did: %w", err) } + span.SetAttributes(attribute.Int("issues.count", len(issues))) + for _, issue := range issues { issueMonth := issue.Created.Month() @@ -129,17 +149,23 @@ func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { *items = append(*items, &issue) } - repos, err := GetAllReposByDid(e, forDid) + repos, err := GetAllReposByDid(ctx, e, forDid) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "error getting all repos by did") return nil, fmt.Errorf("error getting all repos by did: %w", err) } + span.SetAttributes(attribute.Int("repos.count", len(repos))) + for _, repo := range repos { // TODO: get this in the original query; requires COALESCE because nullable var sourceRepo *Repo if repo.Source != "" { - sourceRepo, err = GetRepoByAtUri(e, repo.Source) + sourceRepo, err = GetRepoByAtUri(ctx, e, repo.Source) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "error getting repo by at uri") return nil, err } } diff --git a/appview/db/pulls.go b/appview/db/pulls.go index da6e086..8ffc8fd 100644 --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -1,6 +1,7 @@ package db import ( + "context" "database/sql" "fmt" "log" @@ -10,6 +11,8 @@ import ( "github.com/bluekeyes/go-gitdiff/gitdiff" "github.com/bluesky-social/indigo/atproto/syntax" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/patchutil" "tangled.sh/tangled.sh/core/types" @@ -234,7 +237,18 @@ func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch { return patches } -func NewPull(tx *sql.Tx, pull *Pull) error { +func NewPull(ctx context.Context, tx *sql.Tx, pull *Pull) error { + span := trace.SpanFromContext(ctx) + defer span.End() + + span.SetAttributes( + attribute.String("repo.at", pull.RepoAt.String()), + attribute.String("owner.did", pull.OwnerDid), + attribute.String("title", pull.Title), + attribute.String("target_branch", pull.TargetBranch), + ) + span.AddEvent("creating new pull request") + defer tx.Rollback() _, err := tx.Exec(` @@ -242,6 +256,7 @@ func NewPull(tx *sql.Tx, pull *Pull) error { values (?, 1) `, pull.RepoAt) if err != nil { + span.RecordError(err) return err } @@ -253,12 +268,16 @@ func NewPull(tx *sql.Tx, pull *Pull) error { returning next_pull_id - 1 `, pull.RepoAt).Scan(&nextId) if err != nil { + span.RecordError(err) return err } pull.PullId = nextId pull.State = PullOpen + span.SetAttributes(attribute.Int("pull.id", pull.PullId)) + span.AddEvent("assigned pull ID") + var sourceBranch, sourceRepoAt *string if pull.PullSource != nil { sourceBranch = &pull.PullSource.Branch @@ -284,26 +303,34 @@ func NewPull(tx *sql.Tx, pull *Pull) error { sourceRepoAt, ) if err != nil { + span.RecordError(err) return err } + span.AddEvent("inserted pull record") + _, err = tx.Exec(` insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) values (?, ?, ?, ?, ?) `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) if err != nil { + span.RecordError(err) return err } + span.AddEvent("inserted initial pull submission") + if err := tx.Commit(); err != nil { + span.RecordError(err) return err } + span.AddEvent("transaction committed successfully") return nil } -func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { - pull, err := GetPull(e, repoAt, pullId) +func GetPullAt(ctx context.Context, e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { + pull, err := GetPull(ctx, e, repoAt, pullId) if err != nil { return "", err } @@ -316,10 +343,19 @@ func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { return pullId - 1, err } -func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { +func GetPulls(ctx context.Context, e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { + span := trace.SpanFromContext(ctx) + defer span.End() + + span.SetAttributes( + attribute.String("repoAt", repoAt.String()), + attribute.String("state", state.String()), + ) + span.AddEvent("querying pulls") + pulls := make(map[int]*Pull) - rows, err := e.Query(` + rows, err := e.QueryContext(ctx, ` select owner_did, pull_id, @@ -336,6 +372,7 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { where repo_at = ? and state = ?`, repoAt, state) if err != nil { + span.RecordError(err) return nil, err } defer rows.Close() @@ -357,11 +394,13 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { &sourceRepoAt, ) if err != nil { + span.RecordError(err) return nil, err } createdTime, err := time.Parse(time.RFC3339, createdAt) if err != nil { + span.RecordError(err) return nil, err } pull.Created = createdTime @@ -373,6 +412,7 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { if sourceRepoAt.Valid { sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) if err != nil { + span.RecordError(err) return nil, err } pull.PullSource.RepoAt = &sourceRepoAtParsed @@ -382,6 +422,9 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { pulls[pull.PullId] = &pull } + span.AddEvent("querying pull submissions") + span.SetAttributes(attribute.Int("pull_count", len(pulls))) + // get latest round no. for each pull inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") submissionsQuery := fmt.Sprintf(` @@ -400,8 +443,9 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { args[idx] = p.PullId idx += 1 } - submissionsRows, err := e.Query(submissionsQuery, args...) + submissionsRows, err := e.QueryContext(ctx, submissionsQuery, args...) if err != nil { + span.RecordError(err) return nil, err } defer submissionsRows.Close() @@ -414,6 +458,7 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { &s.RoundNumber, ) if err != nil { + span.RecordError(err) return nil, err } @@ -423,9 +468,12 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { } } if err := rows.Err(); err != nil { + span.RecordError(err) return nil, err } + span.AddEvent("querying pull comments") + // get comment count on latest submission on each pull inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") commentsQuery := fmt.Sprintf(` @@ -443,8 +491,9 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { for _, p := range pulls { args = append(args, p.Submissions[p.LastRoundNumber()].ID) } - commentsRows, err := e.Query(commentsQuery, args...) + commentsRows, err := e.QueryContext(ctx, commentsQuery, args...) if err != nil { + span.RecordError(err) return nil, err } defer commentsRows.Close() @@ -456,6 +505,7 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { &pullId, ) if err != nil { + span.RecordError(err) return nil, err } if p, ok := pulls[pullId]; ok { @@ -463,9 +513,12 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { } } if err := rows.Err(); err != nil { + span.RecordError(err) return nil, err } + span.AddEvent("sorting pulls by date") + orderedByDate := []*Pull{} for _, p := range pulls { orderedByDate = append(orderedByDate, p) @@ -474,10 +527,17 @@ func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { return orderedByDate[i].Created.After(orderedByDate[j].Created) }) + span.SetAttributes(attribute.Int("result_count", len(orderedByDate))) return orderedByDate, nil } -func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { +func GetPull(ctx context.Context, e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { + span := trace.SpanFromContext(ctx) + defer span.End() + + span.SetAttributes(attribute.String("repoAt", repoAt.String()), attribute.Int("pull.id", pullId)) + span.AddEvent("query pull metadata") + query := ` select owner_did, @@ -496,7 +556,7 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { where repo_at = ? and pull_id = ? ` - row := e.QueryRow(query, repoAt, pullId) + row := e.QueryRowContext(ctx, query, repoAt, pullId) var pull Pull var createdAt string @@ -515,16 +575,17 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { &sourceRepoAt, ) if err != nil { + span.RecordError(err) return nil, err } createdTime, err := time.Parse(time.RFC3339, createdAt) if err != nil { + span.RecordError(err) return nil, err } pull.Created = createdTime - // populate source if sourceBranch.Valid { pull.PullSource = &PullSource{ Branch: sourceBranch.String, @@ -532,12 +593,14 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { if sourceRepoAt.Valid { sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) if err != nil { + span.RecordError(err) return nil, err } pull.PullSource.RepoAt = &sourceRepoAtParsed } } + span.AddEvent("query submissions") submissionsQuery := ` select id, pull_id, repo_at, round_number, patch, created, source_rev @@ -546,8 +609,9 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { where repo_at = ? and pull_id = ? ` - submissionsRows, err := e.Query(submissionsQuery, repoAt, pullId) + submissionsRows, err := e.QueryContext(ctx, submissionsQuery, repoAt, pullId) if err != nil { + span.RecordError(err) return nil, err } defer submissionsRows.Close() @@ -568,11 +632,13 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { &submissionSourceRev, ) if err != nil { + span.RecordError(err) return nil, err } submissionCreatedTime, err := time.Parse(time.RFC3339, submissionCreatedStr) if err != nil { + span.RecordError(err) return nil, err } submission.Created = submissionCreatedTime @@ -584,6 +650,7 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { submissionsMap[submission.ID] = &submission } if err = submissionsRows.Close(); err != nil { + span.RecordError(err) return nil, err } if len(submissionsMap) == 0 { @@ -595,6 +662,8 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { args = append(args, k) } inClause := strings.TrimSuffix(strings.Repeat("?, ", len(submissionsMap)), ", ") + + span.AddEvent("query comments") commentsQuery := fmt.Sprintf(` select id, @@ -612,8 +681,9 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { order by created asc `, inClause) - commentsRows, err := e.Query(commentsQuery, args...) + commentsRows, err := e.QueryContext(ctx, commentsQuery, args...) if err != nil { + span.RecordError(err) return nil, err } defer commentsRows.Close() @@ -632,34 +702,34 @@ func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { &commentCreatedStr, ) if err != nil { + span.RecordError(err) return nil, err } commentCreatedTime, err := time.Parse(time.RFC3339, commentCreatedStr) if err != nil { + span.RecordError(err) return nil, err } comment.Created = commentCreatedTime - // Add the comment to its submission if submission, ok := submissionsMap[comment.SubmissionId]; ok { submission.Comments = append(submission.Comments, comment) } - } if err = commentsRows.Err(); err != nil { + span.RecordError(err) return nil, err } - var pullSourceRepo *Repo - if pull.PullSource != nil { - if pull.PullSource.RepoAt != nil { - pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) - if err != nil { - log.Printf("failed to get repo by at uri: %v", err) - } else { - pull.PullSource.Repo = pullSourceRepo - } + if pull.PullSource != nil && pull.PullSource.RepoAt != nil { + span.AddEvent("query pull source repo") + pullSourceRepo, err := GetRepoByAtUri(ctx, e, pull.PullSource.RepoAt.String()) + if err != nil { + span.RecordError(err) + log.Printf("failed to get repo by at uri: %v", err) + } else { + pull.PullSource.Repo = pullSourceRepo } } @@ -747,9 +817,21 @@ func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) { return pulls, nil } -func NewPullComment(e Execer, comment *PullComment) (int64, error) { +func NewPullComment(ctx context.Context, e Execer, comment *PullComment) (int64, error) { + span := trace.SpanFromContext(ctx) + defer span.End() + + span.SetAttributes( + attribute.String("repo.at", comment.RepoAt), + attribute.Int("pull.id", comment.PullId), + attribute.Int("submission.id", comment.SubmissionId), + attribute.String("owner.did", comment.OwnerDid), + ) + span.AddEvent("inserting new pull comment") + query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` - res, err := e.Exec( + res, err := e.ExecContext( + ctx, query, comment.OwnerDid, comment.RepoAt, @@ -759,14 +841,18 @@ func NewPullComment(e Execer, comment *PullComment) (int64, error) { comment.Body, ) if err != nil { + span.RecordError(err) return 0, err } i, err := res.LastInsertId() if err != nil { + span.RecordError(err) return 0, err } + span.SetAttributes(attribute.Int64("comment.id", i)) + span.AddEvent("pull comment created successfully") return i, nil } diff --git a/appview/db/repos.go b/appview/db/repos.go index 8c20144..b8d89f5 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -1,10 +1,13 @@ package db import ( + "context" "database/sql" "time" "github.com/bluesky-social/indigo/atproto/syntax" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" ) type Repo struct { @@ -23,7 +26,11 @@ type Repo struct { Source string } -func GetAllRepos(e Execer, limit int) ([]Repo, error) { +func GetAllRepos(ctx context.Context, e Execer, limit int) ([]Repo, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetAllRepos") + defer span.End() + span.SetAttributes(attribute.Int("limit", limit)) + var repos []Repo rows, err := e.Query( @@ -35,6 +42,7 @@ func GetAllRepos(e Execer, limit int) ([]Repo, error) { limit, ) if err != nil { + span.RecordError(err) return nil, err } defer rows.Close() @@ -45,19 +53,26 @@ func GetAllRepos(e Execer, limit int) ([]Repo, error) { rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, ) if err != nil { + span.RecordError(err) return nil, err } repos = append(repos, repo) } if err := rows.Err(); err != nil { + span.RecordError(err) return nil, err } + span.SetAttributes(attribute.Int("repos.count", len(repos))) return repos, nil } -func GetAllReposByDid(e Execer, did string) ([]Repo, error) { +func GetAllReposByDid(ctx context.Context, e Execer, did string) ([]Repo, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetAllReposByDid") + defer span.End() + span.SetAttributes(attribute.String("did", did)) + var repos []Repo rows, err := e.Query( @@ -81,6 +96,7 @@ func GetAllReposByDid(e Execer, did string) ([]Repo, error) { order by r.created desc`, did) if err != nil { + span.RecordError(err) return nil, err } defer rows.Close() @@ -94,6 +110,7 @@ func GetAllReposByDid(e Execer, did string) ([]Repo, error) { err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) if err != nil { + span.RecordError(err) return nil, err } @@ -118,13 +135,22 @@ func GetAllReposByDid(e Execer, did string) ([]Repo, error) { } if err := rows.Err(); err != nil { + span.RecordError(err) return nil, err } + span.SetAttributes(attribute.Int("repos.count", len(repos))) return repos, nil } -func GetRepo(e Execer, did, name string) (*Repo, error) { +func GetRepo(ctx context.Context, e Execer, did, name string) (*Repo, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetRepo") + defer span.End() + span.SetAttributes( + attribute.String("did", did), + attribute.String("name", name), + ) + var repo Repo var nullableDescription sql.NullString @@ -132,6 +158,7 @@ func GetRepo(e Execer, did, name string) (*Repo, error) { var createdAt string if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { + span.RecordError(err) return nil, err } createdAtTime, _ := time.Parse(time.RFC3339, createdAt) @@ -146,7 +173,11 @@ func GetRepo(e Execer, did, name string) (*Repo, error) { return &repo, nil } -func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { +func GetRepoByAtUri(ctx context.Context, e Execer, atUri string) (*Repo, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetRepoByAtUri") + defer span.End() + span.SetAttributes(attribute.String("atUri", atUri)) + var repo Repo var nullableDescription sql.NullString @@ -154,6 +185,7 @@ func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { var createdAt string if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { + span.RecordError(err) return nil, err } createdAtTime, _ := time.Parse(time.RFC3339, createdAt) @@ -168,31 +200,60 @@ func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) { return &repo, nil } -func AddRepo(e Execer, repo *Repo) error { +func AddRepo(ctx context.Context, e Execer, repo *Repo) error { + ctx, span := otel.Tracer("db").Start(ctx, "AddRepo") + defer span.End() + span.SetAttributes( + attribute.String("did", repo.Did), + attribute.String("name", repo.Name), + ) + _, err := e.Exec( `insert into repos (did, name, knot, rkey, at_uri, description, source) values (?, ?, ?, ?, ?, ?, ?)`, repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, ) + if err != nil { + span.RecordError(err) + } return err } -func RemoveRepo(e Execer, did, name string) error { +func RemoveRepo(ctx context.Context, e Execer, did, name string) error { + ctx, span := otel.Tracer("db").Start(ctx, "RemoveRepo") + defer span.End() + span.SetAttributes( + attribute.String("did", did), + attribute.String("name", name), + ) + _, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name) + if err != nil { + span.RecordError(err) + } return err } -func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) { +func GetRepoSource(ctx context.Context, e Execer, repoAt syntax.ATURI) (string, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetRepoSource") + defer span.End() + span.SetAttributes(attribute.String("repoAt", repoAt.String())) + var nullableSource sql.NullString err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&nullableSource) if err != nil { + span.RecordError(err) return "", err } return nullableSource.String, nil } -func GetForksByDid(e Execer, did string) ([]Repo, error) { +func GetForksByDid(ctx context.Context, e Execer, did string) ([]Repo, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetForksByDid") + defer span.End() + span.SetAttributes(attribute.String("did", did)) + var repos []Repo rows, err := e.Query( @@ -203,6 +264,7 @@ func GetForksByDid(e Execer, did string) ([]Repo, error) { did, ) if err != nil { + span.RecordError(err) return nil, err } defer rows.Close() @@ -215,6 +277,7 @@ func GetForksByDid(e Execer, did string) ([]Repo, error) { err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) if err != nil { + span.RecordError(err) return nil, err } @@ -237,13 +300,22 @@ func GetForksByDid(e Execer, did string) ([]Repo, error) { } if err := rows.Err(); err != nil { + span.RecordError(err) return nil, err } + span.SetAttributes(attribute.Int("forks.count", len(repos))) return repos, nil } -func GetForkByDid(e Execer, did string, name string) (*Repo, error) { +func GetForkByDid(ctx context.Context, e Execer, did string, name string) (*Repo, error) { + ctx, span := otel.Tracer("db").Start(ctx, "GetForkByDid") + defer span.End() + span.SetAttributes( + attribute.String("did", did), + attribute.String("name", name), + ) + var repo Repo var createdAt string var nullableDescription sql.NullString @@ -258,6 +330,7 @@ func GetForkByDid(e Execer, did string, name string) (*Repo, error) { err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) if err != nil { + span.RecordError(err) return nil, err } @@ -279,21 +352,46 @@ func GetForkByDid(e Execer, did string, name string) (*Repo, error) { return &repo, nil } -func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { +func AddCollaborator(ctx context.Context, e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { + ctx, span := otel.Tracer("db").Start(ctx, "AddCollaborator") + defer span.End() + span.SetAttributes( + attribute.String("collaborator", collaborator), + attribute.String("repoOwnerDid", repoOwnerDid), + attribute.String("repoName", repoName), + ) + _, err := e.Exec( `insert into collaborators (did, repo) values (?, (select id from repos where did = ? and name = ? and knot = ?));`, collaborator, repoOwnerDid, repoName, repoKnot) + if err != nil { + span.RecordError(err) + } return err } -func UpdateDescription(e Execer, repoAt, newDescription string) error { +func UpdateDescription(ctx context.Context, e Execer, repoAt, newDescription string) error { + ctx, span := otel.Tracer("db").Start(ctx, "UpdateDescription") + defer span.End() + span.SetAttributes( + attribute.String("repoAt", repoAt), + attribute.String("description", newDescription), + ) + _, err := e.Exec( `update repos set description = ? where at_uri = ?`, newDescription, repoAt) + if err != nil { + span.RecordError(err) + } return err } -func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { +func CollaboratingIn(ctx context.Context, e Execer, collaborator string) ([]Repo, error) { + ctx, span := otel.Tracer("db").Start(ctx, "CollaboratingIn") + defer span.End() + span.SetAttributes(attribute.String("collaborator", collaborator)) + var repos []Repo rows, err := e.Query( @@ -310,6 +408,7 @@ func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { group by r.id;`, collaborator) if err != nil { + span.RecordError(err) return nil, err } defer rows.Close() @@ -322,6 +421,7 @@ func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount) if err != nil { + span.RecordError(err) return nil, err } @@ -344,9 +444,11 @@ func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { } if err := rows.Err(); err != nil { + span.RecordError(err) return nil, err } + span.SetAttributes(attribute.Int("repos.count", len(repos))) return repos, nil } diff --git a/appview/db/star.go b/appview/db/star.go index b836be9..73596aa 100644 --- a/appview/db/star.go +++ b/appview/db/star.go @@ -1,6 +1,7 @@ package db import ( + "context" "log" "time" @@ -17,12 +18,12 @@ type Star struct { Repo *Repo } -func (star *Star) ResolveRepo(e Execer) error { +func (star *Star) ResolveRepo(ctx context.Context, e Execer) error { if star.Repo != nil { return nil } - repo, err := GetRepoByAtUri(e, star.RepoAt.String()) + repo, err := GetRepoByAtUri(ctx, e, star.RepoAt.String()) if err != nil { return err } @@ -40,7 +41,7 @@ func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) er // Get a star record func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { query := ` - select starred_by_did, repo_at, created, rkey + select starred_by_did, repo_at, created, rkey from stars where starred_by_did = ? and repo_at = ?` row := e.QueryRow(query, starredByDid, repoAt) @@ -97,7 +98,7 @@ func GetAllStars(e Execer, limit int) ([]Star, error) { var stars []Star rows, err := e.Query(` - select + select s.starred_by_did, s.repo_at, s.rkey, diff --git a/appview/db/timeline.go b/appview/db/timeline.go index 819a3a7..050c0d5 100644 --- a/appview/db/timeline.go +++ b/appview/db/timeline.go @@ -1,8 +1,12 @@ package db import ( + "context" "sort" "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type TimelineEvent struct { @@ -18,30 +22,49 @@ type TimelineEvent struct { // TODO: this gathers heterogenous events from different sources and aggregates // them in code; if we did this entirely in sql, we could order and limit and paginate easily -func MakeTimeline(e Execer) ([]TimelineEvent, error) { +func MakeTimeline(ctx context.Context, e Execer) ([]TimelineEvent, error) { + span := trace.SpanFromContext(ctx) + defer span.End() + var events []TimelineEvent limit := 50 - repos, err := GetAllRepos(e, limit) + span.SetAttributes(attribute.Int("timeline.limit", limit)) + + repos, err := GetAllRepos(ctx, e, limit) if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("error.from", "GetAllRepos")) return nil, err } + span.SetAttributes(attribute.Int("timeline.repos.count", len(repos))) follows, err := GetAllFollows(e, limit) if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("error.from", "GetAllFollows")) return nil, err } + span.SetAttributes(attribute.Int("timeline.follows.count", len(follows))) stars, err := GetAllStars(e, limit) if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("error.from", "GetAllStars")) return nil, err } + span.SetAttributes(attribute.Int("timeline.stars.count", len(stars))) for _, repo := range repos { var sourceRepo *Repo if repo.Source != "" { - sourceRepo, err = GetRepoByAtUri(e, repo.Source) + sourceRepo, err = GetRepoByAtUri(ctx, e, repo.Source) if err != nil { + span.RecordError(err) + span.SetAttributes( + attribute.String("error.from", "GetRepoByAtUri"), + attribute.String("repo.source", repo.Source), + ) return nil, err } } @@ -76,5 +99,7 @@ func MakeTimeline(e Execer) ([]TimelineEvent, error) { events = events[:limit] } + span.SetAttributes(attribute.Int("timeline.events.total", len(events))) + return events, nil } diff --git a/appview/state/artifact.go b/appview/state/artifact.go index 020cba7..a432092 100644 --- a/appview/state/artifact.go +++ b/appview/state/artifact.go @@ -118,7 +118,7 @@ func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) { s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(r.Context(), s, user), Artifact: artifact, }) } diff --git a/appview/state/middleware.go b/appview/state/middleware.go index c3c5cdb..5c50e96 100644 --- a/appview/state/middleware.go +++ b/appview/state/middleware.go @@ -12,6 +12,7 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/go-chi/chi/v5" + "go.opentelemetry.io/otel/attribute" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/middleware" ) @@ -19,8 +20,11 @@ import ( func knotRoleMiddleware(s *State, group string) middleware.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "knotRoleMiddleware") + defer span.End() + // requires auth also - actor := s.auth.GetUser(r) + actor := s.auth.GetUser(r.WithContext(ctx)) if actor == nil { // we need a logged in user log.Printf("not logged in, redirecting") @@ -41,7 +45,7 @@ func knotRoleMiddleware(s *State, group string) middleware.Middleware { return } - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(ctx)) }) } } @@ -53,15 +57,18 @@ func KnotOwner(s *State) middleware.Middleware { func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "RepoPermissionMiddleware") + defer span.End() + // requires auth also - actor := s.auth.GetUser(r) + actor := s.auth.GetUser(r.WithContext(ctx)) if actor == nil { // we need a logged in user log.Printf("not logged in, redirecting") http.Error(w, "Forbiden", http.StatusUnauthorized) return } - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { http.Error(w, "malformed url", http.StatusBadRequest) return @@ -75,7 +82,7 @@ func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middlewa return } - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(ctx)) }) } } @@ -101,7 +108,10 @@ func ResolveIdent(s *State) middleware.Middleware { return } - id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) + ctx, span := s.t.TraceStart(req.Context(), "ResolveIdent") + defer span.End() + + id, err := s.resolver.ResolveIdent(ctx, didOrHandle) if err != nil { // invalid did or handle log.Println("failed to resolve did/handle:", err) @@ -109,7 +119,7 @@ func ResolveIdent(s *State) middleware.Middleware { return } - ctx := context.WithValue(req.Context(), "resolvedId", *id) + ctx = context.WithValue(ctx, "resolvedId", *id) next.ServeHTTP(w, req.WithContext(ctx)) }) @@ -119,15 +129,18 @@ func ResolveIdent(s *State) middleware.Middleware { func ResolveRepo(s *State) middleware.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx, span := s.t.TraceStart(req.Context(), "ResolveRepo") + defer span.End() + repoName := chi.URLParam(req, "repo") - id, ok := req.Context().Value("resolvedId").(identity.Identity) + id, ok := ctx.Value("resolvedId").(identity.Identity) if !ok { log.Println("malformed middleware") w.WriteHeader(http.StatusInternalServerError) return } - repo, err := db.GetRepo(s.db, id.DID.String(), repoName) + repo, err := db.GetRepo(ctx, s.db, id.DID.String(), repoName) if err != nil { // invalid did or handle log.Println("failed to resolve repo") @@ -135,7 +148,7 @@ func ResolveRepo(s *State) middleware.Middleware { return } - ctx := context.WithValue(req.Context(), "knot", repo.Knot) + ctx = context.WithValue(ctx, "knot", repo.Knot) ctx = context.WithValue(ctx, "repoAt", repo.AtUri) ctx = context.WithValue(ctx, "repoDescription", repo.Description) ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) @@ -148,7 +161,10 @@ func ResolveRepo(s *State) middleware.Middleware { func ResolvePull(s *State) middleware.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "ResolvePull") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to fully resolve repo", err) http.Error(w, "invalid repo url", http.StatusNotFound) @@ -163,13 +179,15 @@ func ResolvePull(s *State) middleware.Middleware { return } - pr, err := db.GetPull(s.db, f.RepoAt, prIdInt) + pr, err := db.GetPull(ctx, s.db, f.RepoAt, prIdInt) if err != nil { log.Println("failed to get pull and comments", err) return } - ctx := context.WithValue(r.Context(), "pull", pr) + span.SetAttributes(attribute.Int("pull.id", prIdInt)) + + ctx = context.WithValue(ctx, "pull", pr) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/appview/state/profile.go b/appview/state/profile.go index f26fe04..747bafa 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -10,36 +10,54 @@ import ( "github.com/bluesky-social/indigo/atproto/identity" "github.com/go-chi/chi/v5" + "go.opentelemetry.io/otel/attribute" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages" ) func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "ProfilePage") + defer span.End() + didOrHandle := chi.URLParam(r, "user") if didOrHandle == "" { http.Error(w, "Bad request", http.StatusBadRequest) return } - ident, ok := r.Context().Value("resolvedId").(identity.Identity) + ident, ok := ctx.Value("resolvedId").(identity.Identity) if !ok { s.pages.Error404(w) + span.RecordError(fmt.Errorf("failed to resolve identity")) return } - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) + span.SetAttributes( + attribute.String("user.did", ident.DID.String()), + attribute.String("user.handle", ident.Handle.String()), + ) + + repos, err := db.GetAllReposByDid(ctx, s.db, ident.DID.String()) if err != nil { log.Printf("getting repos for %s: %s", ident.DID.String(), err) + span.RecordError(err) + span.SetAttributes(attribute.String("error.repos", err.Error())) } + span.SetAttributes(attribute.Int("repos.count", len(repos))) - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) + collaboratingRepos, err := db.CollaboratingIn(ctx, s.db, ident.DID.String()) if err != nil { log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) + span.RecordError(err) + span.SetAttributes(attribute.String("error.collaborating_repos", err.Error())) } + span.SetAttributes(attribute.Int("collaborating_repos.count", len(collaboratingRepos))) - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) + timeline, err := db.MakeProfileTimeline(ctx, s.db, ident.DID.String()) if err != nil { log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) + span.RecordError(err) + span.SetAttributes(attribute.String("error.timeline", err.Error())) } var didsToResolve []string @@ -60,8 +78,9 @@ func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { } } } + span.SetAttributes(attribute.Int("dids_to_resolve.count", len(didsToResolve))) - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, didsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -70,17 +89,26 @@ func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { didHandleMap[identity.DID.String()] = identity.DID.String() } } + span.SetAttributes(attribute.Int("resolved_ids.count", len(resolvedIds))) followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) if err != nil { log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) + span.RecordError(err) + span.SetAttributes(attribute.String("error.follow_stats", err.Error())) } + span.SetAttributes( + attribute.Int("followers.count", followers), + attribute.Int("following.count", following), + ) loggedInUser := s.auth.GetUser(r) followStatus := db.IsNotFollowing if loggedInUser != nil { followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) + span.SetAttributes(attribute.String("logged_in_user.did", loggedInUser.Did)) } + span.SetAttributes(attribute.String("follow_status", string(db.FollowStatus(followStatus)))) profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) s.pages.ProfilePage(w, pages.ProfilePageParams{ diff --git a/appview/state/pull.go b/appview/state/pull.go index db6667d..bb5fda8 100644 --- a/appview/state/pull.go +++ b/appview/state/pull.go @@ -1,6 +1,7 @@ package state import ( + "context" "database/sql" "encoding/json" "errors" @@ -11,12 +12,14 @@ import ( "strconv" "time" + "go.opentelemetry.io/otel/attribute" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/auth" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages" "tangled.sh/tangled.sh/core/patchutil" + "tangled.sh/tangled.sh/core/telemetry" "tangled.sh/tangled.sh/core/types" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -27,16 +30,19 @@ import ( // htmx fragment func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "PullActions") + defer span.End() + switch r.Method { case http.MethodGet: user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") @@ -54,39 +60,59 @@ func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { return } - mergeCheckResponse := s.mergeCheck(f, pull) + _, mergeSpan := s.t.TraceStart(ctx, "mergeCheck") + mergeCheckResponse := s.mergeCheck(ctx, f, pull) + mergeSpan.End() + resubmitResult := pages.Unknown if user.Did == pull.OwnerDid { - resubmitResult = s.resubmitCheck(f, pull) + _, resubmitSpan := s.t.TraceStart(ctx, "resubmitCheck") + resubmitResult = s.resubmitCheck(ctx, f, pull) + resubmitSpan.End() } + _, renderSpan := s.t.TraceStart(ctx, "renderPullActions") s.pages.PullActionsFragment(w, pages.PullActionsParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Pull: pull, RoundNumber: roundNumber, MergeCheck: mergeCheckResponse, ResubmitCheck: resubmitResult, }) + renderSpan.End() return } } func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "RepoSinglePull") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { - log.Println("failed to get pull") + err := errors.New("failed to get pull from context") + log.Println(err) + span.RecordError(err) s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } + attrs := telemetry.MapAttrs[string](map[string]string{ + "pull.id": fmt.Sprintf("%d", pull.PullId), + "pull.owner": pull.OwnerDid, + }) + + span.SetAttributes(attrs...) + totalIdents := 1 for _, submission := range pull.Submissions { totalIdents += len(submission.Comments) @@ -104,7 +130,7 @@ func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { } } - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -113,16 +139,18 @@ func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { didHandleMap[identity.DID.String()] = identity.DID.String() } } + span.SetAttributes(attribute.Int("identities.resolved", len(resolvedIds))) + + mergeCheckResponse := s.mergeCheck(ctx, f, pull) - mergeCheckResponse := s.mergeCheck(f, pull) resubmitResult := pages.Unknown if user != nil && user.Did == pull.OwnerDid { - resubmitResult = s.resubmitCheck(f, pull) + resubmitResult = s.resubmitCheck(ctx, f, pull) } s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), DidHandleMap: didHandleMap, Pull: pull, MergeCheck: mergeCheckResponse, @@ -130,7 +158,7 @@ func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { }) } -func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse { +func (s *State) mergeCheck(ctx context.Context, f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse { if pull.State == db.PullMerged { return types.MergeCheckResponse{} } @@ -190,8 +218,14 @@ func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheck return mergeCheckResponse } -func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult { +func (s *State) resubmitCheck(ctx context.Context, f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult { + ctx, span := s.t.TraceStart(ctx, "resubmitCheck") + defer span.End() + + span.SetAttributes(attribute.Int("pull.id", pull.PullId)) + if pull.State == db.PullMerged || pull.PullSource == nil { + span.SetAttributes(attribute.String("result", "Unknown")) return pages.Unknown } @@ -199,9 +233,13 @@ func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.Resubmi if pull.PullSource.RepoAt != nil { // fork-based pulls - sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) + span.SetAttributes(attribute.Bool("isForkBased", true)) + sourceRepo, err := db.GetRepoByAtUri(ctx, s.db, pull.PullSource.RepoAt.String()) if err != nil { log.Println("failed to get source repo", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_get_source_repo")) + span.SetAttributes(attribute.String("result", "Unknown")) return pages.Unknown } @@ -210,26 +248,43 @@ func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.Resubmi repoName = sourceRepo.Name } else { // pulls within the same repo + span.SetAttributes(attribute.Bool("isBranchBased", true)) knot = f.Knot ownerDid = f.OwnerDid() repoName = f.RepoName } + span.SetAttributes( + attribute.String("knot", knot), + attribute.String("ownerDid", ownerDid), + attribute.String("repoName", repoName), + attribute.String("sourceBranch", pull.PullSource.Branch), + ) + us, err := NewUnsignedClient(knot, s.config.Dev) if err != nil { log.Printf("failed to setup client for %s; ignoring: %v", knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_setup_client")) + span.SetAttributes(attribute.String("result", "Unknown")) return pages.Unknown } resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver")) + span.SetAttributes(attribute.String("result", "Unknown")) return pages.Unknown } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("error reading response body: %v", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_read_response")) + span.SetAttributes(attribute.String("result", "Unknown")) return pages.Unknown } defer resp.Body.Close() @@ -237,29 +292,46 @@ func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.Resubmi var result types.RepoBranchResponse if err := json.Unmarshal(body, &result); err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_parse_response")) + span.SetAttributes(attribute.String("result", "Unknown")) return pages.Unknown } latestSubmission := pull.Submissions[pull.LastRoundNumber()] + + span.SetAttributes( + attribute.String("latestSubmission.SourceRev", latestSubmission.SourceRev), + attribute.String("branch.Hash", result.Branch.Hash), + ) + if latestSubmission.SourceRev != result.Branch.Hash { fmt.Println(latestSubmission.SourceRev, result.Branch.Hash) + span.SetAttributes(attribute.String("result", "ShouldResubmit")) return pages.ShouldResubmit } + span.SetAttributes(attribute.String("result", "ShouldNotResubmit")) return pages.ShouldNotResubmit } func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoPullPatch") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { - log.Println("failed to get pull") + err := errors.New("failed to get pull from context") + log.Println(err) + span.RecordError(err) s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } @@ -269,11 +341,19 @@ func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { if err != nil || roundIdInt >= len(pull.Submissions) { http.Error(w, "bad round id", http.StatusBadRequest) log.Println("failed to parse round id", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "bad_round_id")) return } + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.Int("round", roundIdInt), + attribute.String("pull.owner", pull.OwnerDid), + ) + identsToResolve := []string{pull.OwnerDid} - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -282,53 +362,61 @@ func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { didHandleMap[identity.DID.String()] = identity.DID.String() } } + span.SetAttributes(attribute.Int("identities.resolved", len(resolvedIds))) diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch) s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ LoggedInUser: user, DidHandleMap: didHandleMap, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Pull: pull, Round: roundIdInt, Submission: pull.Submissions[roundIdInt], Diff: &diff, }) - } func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "RepoPullInterdiff") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") s.pages.Notice(w, "pull-error", "Failed to get pull.") return } + _, roundSpan := s.t.TraceStart(ctx, "parseRound") roundId := chi.URLParam(r, "round") roundIdInt, err := strconv.Atoi(roundId) if err != nil || roundIdInt >= len(pull.Submissions) { http.Error(w, "bad round id", http.StatusBadRequest) log.Println("failed to parse round id", err) + roundSpan.End() return } if roundIdInt == 0 { http.Error(w, "bad round id", http.StatusBadRequest) log.Println("cannot interdiff initial submission") + roundSpan.End() return } + roundSpan.End() + _, identSpan := s.t.TraceStart(ctx, "resolveIdentities") identsToResolve := []string{pull.OwnerDid} - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -337,11 +425,14 @@ func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { didHandleMap[identity.DID.String()] = identity.DID.String() } } + identSpan.End() + _, diffSpan := s.t.TraceStart(ctx, "calculateInterdiff") currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) if err != nil { log.Println("failed to interdiff; current patch malformed") s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") + diffSpan.End() return } @@ -349,40 +440,51 @@ func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { if err != nil { log.Println("failed to interdiff; previous patch malformed") s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") + diffSpan.End() return } interdiff := patchutil.Interdiff(previousPatch, currentPatch) + diffSpan.End() + _, renderSpan := s.t.TraceStart(ctx, "renderInterdiffPage") s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ - LoggedInUser: s.auth.GetUser(r), - RepoInfo: f.RepoInfo(s, user), + LoggedInUser: s.auth.GetUser(r.WithContext(ctx)), + RepoInfo: f.RepoInfo(ctx, s, user), Pull: pull, Round: roundIdInt, DidHandleMap: didHandleMap, Interdiff: interdiff, }) + renderSpan.End() return } func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { - pull, ok := r.Context().Value("pull").(*db.Pull) + ctx, span := s.t.TraceStart(r.Context(), "RepoPullPatchRaw") + defer span.End() + + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } + _, roundSpan := s.t.TraceStart(ctx, "parseRound") roundId := chi.URLParam(r, "round") roundIdInt, err := strconv.Atoi(roundId) if err != nil || roundIdInt >= len(pull.Submissions) { http.Error(w, "bad round id", http.StatusBadRequest) log.Println("failed to parse round id", err) + roundSpan.End() return } + roundSpan.End() + _, identSpan := s.t.TraceStart(ctx, "resolveIdentities") identsToResolve := []string{pull.OwnerDid} - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -391,15 +493,22 @@ func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { didHandleMap[identity.DID.String()] = identity.DID.String() } } + identSpan.End() + _, writeSpan := s.t.TraceStart(ctx, "writePatch") w.Header().Set("Content-Type", "text/plain") w.Write([]byte(pull.Submissions[roundIdInt].Patch)) + writeSpan.End() } func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "RepoPulls") + defer span.End() + user := s.auth.GetUser(r) params := r.URL.Query() + _, stateSpan := s.t.TraceStart(ctx, "determinePullState") state := db.PullOpen switch params.Get("state") { case "closed": @@ -407,25 +516,33 @@ func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { case "merged": state = db.PullMerged } + stateSpan.End() - f, err := s.fullyResolvedRepo(r) + _, repoSpan := s.t.TraceStart(ctx, "resolveRepo") + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + repoSpan.End() return } + repoSpan.End() - pulls, err := db.GetPulls(s.db, f.RepoAt, state) + _, pullsSpan := s.t.TraceStart(ctx, "getPulls") + pulls, err := db.GetPulls(ctx, s.db, f.RepoAt, state) if err != nil { log.Println("failed to get pulls", err) s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") + pullsSpan.End() return } + pullsSpan.End() + _, sourceRepoSpan := s.t.TraceStart(ctx, "resolvePullSources") for _, p := range pulls { var pullSourceRepo *db.Repo if p.PullSource != nil { if p.PullSource.RepoAt != nil { - pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) + pullSourceRepo, err = db.GetRepoByAtUri(ctx, s.db, p.PullSource.RepoAt.String()) if err != nil { log.Printf("failed to get repo by at uri: %v", err) continue @@ -435,12 +552,14 @@ func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { } } } + sourceRepoSpan.End() + _, identSpan := s.t.TraceStart(ctx, "resolveIdentities") identsToResolve := make([]string, len(pulls)) for i, pull := range pulls { identsToResolve[i] = pull.OwnerDid } - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -449,78 +568,102 @@ func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { didHandleMap[identity.DID.String()] = identity.DID.String() } } + identSpan.End() + _, renderSpan := s.t.TraceStart(ctx, "renderPullsPage") s.pages.RepoPulls(w, pages.RepoPullsParams{ - LoggedInUser: s.auth.GetUser(r), - RepoInfo: f.RepoInfo(s, user), + LoggedInUser: s.auth.GetUser(r.WithContext(ctx)), + RepoInfo: f.RepoInfo(ctx, s, user), Pulls: pulls, DidHandleMap: didHandleMap, FilteringBy: state, }) + renderSpan.End() return } func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "PullComment") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } + _, roundSpan := s.t.TraceStart(ctx, "parseRoundNumber") roundNumberStr := chi.URLParam(r, "round") roundNumber, err := strconv.Atoi(roundNumberStr) if err != nil || roundNumber >= len(pull.Submissions) { http.Error(w, "bad round id", http.StatusBadRequest) log.Println("failed to parse round id", err) + roundSpan.End() return } + roundSpan.End() switch r.Method { case http.MethodGet: + _, renderSpan := s.t.TraceStart(ctx, "renderCommentFragment") s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Pull: pull, RoundNumber: roundNumber, }) + renderSpan.End() return case http.MethodPost: + postCtx, postSpan := s.t.TraceStart(ctx, "CreateComment") + defer postSpan.End() + + _, validateSpan := s.t.TraceStart(postCtx, "validateComment") body := r.FormValue("body") if body == "" { s.pages.Notice(w, "pull", "Comment body is required") + validateSpan.End() return } + validateSpan.End() // Start a transaction - tx, err := s.db.BeginTx(r.Context(), nil) + _, txSpan := s.t.TraceStart(postCtx, "startTransaction") + tx, err := s.db.BeginTx(postCtx, nil) if err != nil { log.Println("failed to start transaction", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") + txSpan.End() return } defer tx.Rollback() + txSpan.End() createdAt := time.Now().Format(time.RFC3339) ownerDid := user.Did - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) + _, pullAtSpan := s.t.TraceStart(postCtx, "getPullAt") + pullAt, err := db.GetPullAt(postCtx, s.db, f.RepoAt, pull.PullId) if err != nil { log.Println("failed to get pull at", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") + pullAtSpan.End() return } + pullAtSpan.End() + _, atProtoSpan := s.t.TraceStart(postCtx, "createAtProtoRecord") atUri := f.RepoAt.String() - client, _ := s.auth.AuthorizedClient(r) - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + client, _ := s.auth.AuthorizedClient(r.WithContext(postCtx)) + atResp, err := comatproto.RepoPutRecord(postCtx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullCommentNSID, Repo: user.Did, Rkey: appview.TID(), @@ -537,11 +680,14 @@ func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { if err != nil { log.Println("failed to create pull comment", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") + atProtoSpan.End() return } + atProtoSpan.End() // Create the pull comment in the database with the commentAt field - commentId, err := db.NewPullComment(tx, &db.PullComment{ + _, dbSpan := s.t.TraceStart(postCtx, "createDbComment") + commentId, err := db.NewPullComment(postCtx, tx, &db.PullComment{ OwnerDid: user.Did, RepoAt: f.RepoAt.String(), PullId: pull.PullId, @@ -552,10 +698,11 @@ func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { if err != nil { log.Println("failed to create pull comment", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") + dbSpan.End() return } + dbSpan.End() - // Commit the transaction if err = tx.Commit(); err != nil { log.Println("failed to commit transaction", err) s.pages.Notice(w, "pull-comment", "Failed to create comment.") @@ -568,18 +715,25 @@ func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { } func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "NewPull") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } switch r.Method { case http.MethodGet: + span.SetAttributes(attribute.String("method", "GET")) + us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Printf("failed to create unsigned client for %s", f.Knot) + span.RecordError(err) s.pages.Error503(w) return } @@ -587,12 +741,14 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { resp, err := us.Branches(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) return } @@ -600,15 +756,18 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) return } s.pages.RepoNewPull(w, pages.RepoNewPullParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Branches: result.Branches, }) case http.MethodPost: + span.SetAttributes(attribute.String("method", "POST")) + title := r.FormValue("title") body := r.FormValue("body") targetBranch := r.FormValue("targetBranch") @@ -616,20 +775,36 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { sourceBranch := r.FormValue("sourceBranch") patch := r.FormValue("patch") + span.SetAttributes( + attribute.String("targetBranch", targetBranch), + attribute.String("sourceBranch", sourceBranch), + attribute.Bool("hasFork", fromFork != ""), + attribute.Bool("hasPatch", patch != ""), + ) + if targetBranch == "" { s.pages.Notice(w, "pull", "Target branch is required.") + span.SetAttributes(attribute.String("error", "missing_target_branch")) return } // Determine PR type based on input parameters - isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() + isPushAllowed := f.RepoInfo(ctx, s, user).Roles.IsPushAllowed() isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" isForkBased := fromFork != "" && sourceBranch != "" isPatchBased := patch != "" && !isBranchBased && !isForkBased + span.SetAttributes( + attribute.Bool("isPushAllowed", isPushAllowed), + attribute.Bool("isBranchBased", isBranchBased), + attribute.Bool("isForkBased", isForkBased), + attribute.Bool("isPatchBased", isPatchBased), + ) + if isPatchBased && !patchutil.IsFormatPatch(patch) { if title == "" { s.pages.Notice(w, "pull", "Title is required for git-diff patches.") + span.SetAttributes(attribute.String("error", "missing_title_for_git_diff")) return } } @@ -637,18 +812,21 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { // Validate we have at least one valid PR creation method if !isBranchBased && !isPatchBased && !isForkBased { s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") + span.SetAttributes(attribute.String("error", "no_valid_pr_method")) return } // Can't mix branch-based and patch-based approaches if isBranchBased && patch != "" { s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") + span.SetAttributes(attribute.String("error", "mixed_pr_methods")) return } us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) + span.RecordError(err) s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") return } @@ -656,12 +834,21 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { caps, err := us.Capabilities() if err != nil { log.Println("error fetching knot caps", f.Knot, err) + span.RecordError(err) s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") return } + span.SetAttributes( + attribute.Bool("caps.pullRequests.formatPatch", caps.PullRequests.FormatPatch), + attribute.Bool("caps.pullRequests.branchSubmissions", caps.PullRequests.BranchSubmissions), + attribute.Bool("caps.pullRequests.forkSubmissions", caps.PullRequests.ForkSubmissions), + attribute.Bool("caps.pullRequests.patchSubmissions", caps.PullRequests.PatchSubmissions), + ) + if !caps.PullRequests.FormatPatch { s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") + span.SetAttributes(attribute.String("error", "formatpatch_not_supported")) return } @@ -669,27 +856,38 @@ func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { if isBranchBased { if !caps.PullRequests.BranchSubmissions { s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") + span.SetAttributes(attribute.String("error", "branch_submissions_not_supported")) return } - s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch) + s.handleBranchBasedPull(w, r.WithContext(ctx), f, user, title, body, targetBranch, sourceBranch) } else if isForkBased { if !caps.PullRequests.ForkSubmissions { s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") + span.SetAttributes(attribute.String("error", "fork_submissions_not_supported")) return } - s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch) + s.handleForkBasedPull(w, r.WithContext(ctx), f, user, fromFork, title, body, targetBranch, sourceBranch) } else if isPatchBased { if !caps.PullRequests.PatchSubmissions { s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") + span.SetAttributes(attribute.String("error", "patch_submissions_not_supported")) return } - s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch) + s.handlePatchBasedPull(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch) } return } } func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) { + ctx, span := s.t.TraceStart(r.Context(), "handleBranchBasedPull") + defer span.End() + + span.SetAttributes( + attribute.String("targetBranch", targetBranch), + attribute.String("sourceBranch", sourceBranch), + ) + pullSource := &db.PullSource{ Branch: sourceBranch, } @@ -701,6 +899,8 @@ func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Printf("failed to create signed client for %s: %s", f.Knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "client_creation_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } @@ -708,6 +908,8 @@ func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) if err != nil { log.Println("failed to compare", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "comparison_failed")) s.pages.Notice(w, "pull", err.Error()) return } @@ -715,30 +917,51 @@ func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f sourceRev := comparison.Rev2 patch := comparison.Patch + span.SetAttributes(attribute.String("sourceRev", sourceRev)) + if !patchutil.IsPatchValid(patch) { + span.SetAttributes(attribute.String("error", "invalid_patch_format")) s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") return } - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) + s.createPullRequest(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) } func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { + ctx, span := s.t.TraceStart(r.Context(), "handlePatchBasedPull") + defer span.End() + + span.SetAttributes(attribute.String("targetBranch", targetBranch)) + if !patchutil.IsPatchValid(patch) { + span.SetAttributes(attribute.String("error", "invalid_patch_format")) s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") return } - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) + s.createPullRequest(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch, "", nil, nil) } func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { - fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) + ctx, span := s.t.TraceStart(r.Context(), "handleForkBasedPull") + defer span.End() + + span.SetAttributes( + attribute.String("forkRepo", forkRepo), + attribute.String("targetBranch", targetBranch), + attribute.String("sourceBranch", sourceBranch), + ) + + fork, err := db.GetForkByDid(ctx, s.db, user.Did, forkRepo) if errors.Is(err, sql.ErrNoRows) { + span.SetAttributes(attribute.String("error", "fork_not_found")) s.pages.Notice(w, "pull", "No such fork.") return } else if err != nil { log.Println("failed to fetch fork:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "fork_fetch_failed")) s.pages.Notice(w, "pull", "Failed to fetch fork.") return } @@ -746,6 +969,8 @@ func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *F secret, err := db.GetRegistrationKey(s.db, fork.Knot) if err != nil { log.Println("failed to fetch registration key:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "registration_key_fetch_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } @@ -753,6 +978,8 @@ func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *F sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev) if err != nil { log.Println("failed to create signed client:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "signed_client_creation_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } @@ -760,6 +987,8 @@ func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *F us, err := NewUnsignedClient(fork.Knot, s.config.Dev) if err != nil { log.Println("failed to create unsigned client:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "unsigned_client_creation_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } @@ -767,18 +996,24 @@ func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *F resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) if err != nil { log.Println("failed to create hidden ref:", err, resp.StatusCode) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "hidden_ref_creation_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } switch resp.StatusCode { case 404: + span.SetAttributes(attribute.String("error", "not_found_status")) case 400: + span.SetAttributes(attribute.String("error", "bad_request_status")) s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") return } hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) + span.SetAttributes(attribute.String("hiddenRef", hiddenRef)) + // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking // the targetBranch on the target repository. This code is a bit confusing, but here's an example: // hiddenRef: hidden/feature-1/main (on repo-fork) @@ -787,14 +1022,18 @@ func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *F comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) if err != nil { log.Println("failed to compare across branches", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "branch_comparison_failed")) s.pages.Notice(w, "pull", err.Error()) return } sourceRev := comparison.Rev2 patch := comparison.Patch + span.SetAttributes(attribute.String("sourceRev", sourceRev)) if !patchutil.IsPatchValid(patch) { + span.SetAttributes(attribute.String("error", "invalid_patch_format")) s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") return } @@ -802,11 +1041,13 @@ func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *F forkAtUri, err := syntax.ParseATURI(fork.AtUri) if err != nil { log.Println("failed to parse fork AT URI", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "fork_aturi_parse_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ + s.createPullRequest(w, r.WithContext(ctx), f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ Branch: sourceBranch, RepoAt: &forkAtUri, }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) @@ -823,9 +1064,20 @@ func (s *State) createPullRequest( pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source, ) { - tx, err := s.db.BeginTx(r.Context(), nil) + ctx, span := s.t.TraceStart(r.Context(), "createPullRequest") + defer span.End() + + span.SetAttributes( + attribute.String("targetBranch", targetBranch), + attribute.String("sourceRev", sourceRev), + attribute.Bool("hasPullSource", pullSource != nil), + ) + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start tx") + span.RecordError(err) + span.SetAttributes(attribute.String("error", "transaction_start_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } @@ -836,16 +1088,23 @@ func (s *State) createPullRequest( if title == "" { formatPatches, err := patchutil.ExtractPatches(patch) if err != nil { + span.RecordError(err) + span.SetAttributes(attribute.String("error", "extract_patches_failed")) s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) return } if len(formatPatches) == 0 { + span.SetAttributes(attribute.String("error", "no_patches_found")) s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") return } title = formatPatches[0].Title body = formatPatches[0].Body + span.SetAttributes( + attribute.Bool("title_extracted", true), + attribute.Bool("body_extracted", formatPatches[0].Body != ""), + ) } rkey := appview.TID() @@ -853,7 +1112,7 @@ func (s *State) createPullRequest( Patch: patch, SourceRev: sourceRev, } - err = db.NewPull(tx, &db.Pull{ + err = db.NewPull(ctx, tx, &db.Pull{ Title: title, Body: body, TargetBranch: targetBranch, @@ -867,18 +1126,24 @@ func (s *State) createPullRequest( }) if err != nil { log.Println("failed to create pull request", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "db_create_pull_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } - client, _ := s.auth.AuthorizedClient(r) + + client, _ := s.auth.AuthorizedClient(r.WithContext(ctx)) pullId, err := db.NextPullId(s.db, f.RepoAt) if err != nil { log.Println("failed to get pull id", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "get_pull_id_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } + span.SetAttributes(attribute.Int("pullId", pullId)) - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullNSID, Repo: user.Did, Rkey: rkey, @@ -896,6 +1161,16 @@ func (s *State) createPullRequest( if err != nil { log.Println("failed to create pull request", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "atproto_create_record_failed")) + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") + return + } + + if err = tx.Commit(); err != nil { + log.Println("failed to commit transaction", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "transaction_commit_failed")) s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") return } @@ -904,24 +1179,36 @@ func (s *State) createPullRequest( } func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { - _, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "ValidatePatch") + defer span.End() + + _, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "resolve_repo_failed")) return } patch := r.FormValue("patch") + span.SetAttributes(attribute.Bool("hasPatch", patch != "")) + if patch == "" { + span.SetAttributes(attribute.String("error", "empty_patch")) s.pages.Notice(w, "patch-error", "Patch is required.") return } - if patch == "" || !patchutil.IsPatchValid(patch) { + if !patchutil.IsPatchValid(patch) { + span.SetAttributes(attribute.String("error", "invalid_patch_format")) s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") return } - if patchutil.IsFormatPatch(patch) { + isFormatPatch := patchutil.IsFormatPatch(patch) + span.SetAttributes(attribute.Bool("isFormatPatch", isFormatPatch)) + + if isFormatPatch { s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.") } else { s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") @@ -929,29 +1216,41 @@ func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { } func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "PatchUploadFragment") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "resolve_repo_failed")) return } s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), }) } func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "CompareBranchesFragment") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "resolve_repo_failed")) return } us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Printf("failed to create unsigned client for %s", f.Knot) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "client_creation_failed")) s.pages.Error503(w) return } @@ -959,69 +1258,89 @@ func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) resp, err := us.Branches(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "knotserver_connection_failed")) return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "response_read_failed")) return } + defer resp.Body.Close() var result types.RepoBranchesResponse err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "response_parse_failed")) return } + span.SetAttributes(attribute.Int("branches.count", len(result.Branches))) s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Branches: result.Branches, }) } func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "CompareForksFragment") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } - forks, err := db.GetForksByDid(s.db, user.Did) + forks, err := db.GetForksByDid(ctx, s.db, user.Did) if err != nil { log.Println("failed to get forks", err) + span.RecordError(err) return } s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Forks: forks, }) } func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) + ctx, span := s.t.TraceStart(r.Context(), "CompareForksBranchesFragment") + defer span.End() - f, err := s.fullyResolvedRepo(r) + user := s.auth.GetUser(r.WithContext(ctx)) + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } forkVal := r.URL.Query().Get("fork") + span.SetAttributes(attribute.String("fork", forkVal)) // fork repo - repo, err := db.GetRepo(s.db, user.Did, forkVal) + repo, err := db.GetRepo(ctx, s.db, user.Did, forkVal) if err != nil { log.Println("failed to get repo", user.Did, forkVal) + span.RecordError(err) return } sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev) if err != nil { log.Printf("failed to create unsigned client for %s", repo.Knot) + span.RecordError(err) s.pages.Error503(w) return } @@ -1029,12 +1348,14 @@ func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Requ sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) if err != nil { log.Println("failed to reach knotserver for source branches", err) + span.RecordError(err) return } sourceBody, err := io.ReadAll(sourceResp.Body) if err != nil { log.Println("failed to read source response body", err) + span.RecordError(err) return } defer sourceResp.Body.Close() @@ -1043,12 +1364,14 @@ func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Requ err = json.Unmarshal(sourceBody, &sourceResult) if err != nil { log.Println("failed to parse source branches response:", err) + span.RecordError(err) return } targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Printf("failed to create unsigned client for target knot %s", f.Knot) + span.RecordError(err) s.pages.Error503(w) return } @@ -1056,12 +1379,14 @@ func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Requ targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver for target branches", err) + span.RecordError(err) return } targetBody, err := io.ReadAll(targetResp.Body) if err != nil { log.Println("failed to read target response body", err) + span.RecordError(err) return } defer targetResp.Body.Close() @@ -1070,84 +1395,114 @@ func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Requ err = json.Unmarshal(targetBody, &targetResult) if err != nil { log.Println("failed to parse target branches response:", err) + span.RecordError(err) return } s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), SourceBranches: sourceResult.Branches, TargetBranches: targetResult.Branches, }) } func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "ResubmitPull") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") + span.RecordError(errors.New("failed to get pull from context")) s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.String("pull.owner", pull.OwnerDid), + attribute.String("method", r.Method), + ) + switch r.Method { case http.MethodGet: s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Pull: pull, }) return case http.MethodPost: if pull.IsPatchBased() { - s.resubmitPatch(w, r) + span.SetAttributes(attribute.String("pull.type", "patch_based")) + s.resubmitPatch(w, r.WithContext(ctx)) return } else if pull.IsBranchBased() { - s.resubmitBranch(w, r) + span.SetAttributes(attribute.String("pull.type", "branch_based")) + s.resubmitBranch(w, r.WithContext(ctx)) return } else if pull.IsForkBased() { - s.resubmitFork(w, r) + span.SetAttributes(attribute.String("pull.type", "fork_based")) + s.resubmitFork(w, r.WithContext(ctx)) return } + span.SetAttributes(attribute.String("pull.type", "unknown")) } } func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) + ctx, span := s.t.TraceStart(r.Context(), "resubmitPatch") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") + span.RecordError(errors.New("failed to get pull from context")) s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } - f, err := s.fullyResolvedRepo(r) + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.String("pull.owner", pull.OwnerDid), + ) + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } if user.Did != pull.OwnerDid { log.Println("unauthorized user") + span.SetAttributes(attribute.String("error", "unauthorized_user")) w.WriteHeader(http.StatusUnauthorized) return } patch := r.FormValue("patch") + span.SetAttributes(attribute.Bool("has_patch", patch != "")) if err = validateResubmittedPatch(pull, patch); err != nil { + span.SetAttributes(attribute.String("error", "invalid_patch")) s.pages.Notice(w, "resubmit-error", err.Error()) return } - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start tx") + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } @@ -1156,19 +1511,22 @@ func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { err = db.ResubmitPull(tx, pull, patch, "") if err != nil { log.Println("failed to resubmit pull request", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.") return } - client, _ := s.auth.AuthorizedClient(r) + client, _ := s.auth.AuthorizedClient(r.WithContext(ctx)) - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) + ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) if err != nil { // failed to get record + span.RecordError(err) + span.SetAttributes(attribute.String("error", "record_not_found")) s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") return } - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullNSID, Repo: user.Did, Rkey: pull.Rkey, @@ -1185,12 +1543,14 @@ func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println("failed to update record", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") return } if err = tx.Commit(); err != nil { log.Println("failed to commit transaction", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") return } @@ -1200,29 +1560,43 @@ func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { } func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) + ctx, span := s.t.TraceStart(r.Context(), "resubmitBranch") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") + span.RecordError(errors.New("failed to get pull from context")) + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } - f, err := s.fullyResolvedRepo(r) + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.String("pull.owner", pull.OwnerDid), + attribute.String("pull.source_branch", pull.PullSource.Branch), + attribute.String("pull.target_branch", pull.TargetBranch), + ) + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } if user.Did != pull.OwnerDid { log.Println("unauthorized user") + span.SetAttributes(attribute.String("error", "unauthorized_user")) w.WriteHeader(http.StatusUnauthorized) return } - if !f.RepoInfo(s, user).Roles.IsPushAllowed() { + if !f.RepoInfo(ctx, s, user).Roles.IsPushAllowed() { log.Println("unauthorized user") + span.SetAttributes(attribute.String("error", "push_not_allowed")) w.WriteHeader(http.StatusUnauthorized) return } @@ -1230,6 +1604,8 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Printf("failed to create client for %s: %s", f.Knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "client_creation_failed")) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } @@ -1237,26 +1613,32 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) if err != nil { log.Printf("compare request failed: %s", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "compare_failed")) s.pages.Notice(w, "resubmit-error", err.Error()) return } sourceRev := comparison.Rev2 patch := comparison.Patch + span.SetAttributes(attribute.String("source_rev", sourceRev)) if err = validateResubmittedPatch(pull, patch); err != nil { + span.SetAttributes(attribute.String("error", "invalid_patch")) s.pages.Notice(w, "resubmit-error", err.Error()) return } if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { + span.SetAttributes(attribute.String("error", "no_changes")) s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") return } - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start tx") + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } @@ -1265,14 +1647,17 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { err = db.ResubmitPull(tx, pull, patch, sourceRev) if err != nil { log.Println("failed to create pull request", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } - client, _ := s.auth.AuthorizedClient(r) + client, _ := s.auth.AuthorizedClient(r.WithContext(ctx)) - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) + ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) if err != nil { // failed to get record + span.RecordError(err) + span.SetAttributes(attribute.String("error", "record_not_found")) s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") return } @@ -1280,7 +1665,7 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { recordPullSource := &tangled.RepoPull_Source{ Branch: pull.PullSource.Branch, } - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullNSID, Repo: user.Did, Rkey: pull.Rkey, @@ -1298,12 +1683,14 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println("failed to update record", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") return } if err = tx.Commit(); err != nil { log.Println("failed to commit transaction", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") return } @@ -1313,38 +1700,61 @@ func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { } func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) + ctx, span := s.t.TraceStart(r.Context(), "resubmitFork") + defer span.End() - pull, ok := r.Context().Value("pull").(*db.Pull) + user := s.auth.GetUser(r.WithContext(ctx)) + + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") + span.RecordError(errors.New("failed to get pull from context")) + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } - f, err := s.fullyResolvedRepo(r) + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.String("pull.owner", pull.OwnerDid), + attribute.String("pull.source_branch", pull.PullSource.Branch), + attribute.String("pull.target_branch", pull.TargetBranch), + ) + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) return } if user.Did != pull.OwnerDid { log.Println("unauthorized user") + span.SetAttributes(attribute.String("error", "unauthorized_user")) w.WriteHeader(http.StatusUnauthorized) return } - forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) + forkRepo, err := db.GetRepoByAtUri(ctx, s.db, pull.PullSource.RepoAt.String()) if err != nil { log.Println("failed to get source repo", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "source_repo_not_found")) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } + span.SetAttributes( + attribute.String("fork.knot", forkRepo.Knot), + attribute.String("fork.did", forkRepo.Did), + attribute.String("fork.name", forkRepo.Name), + ) + // extract patch by performing compare ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev) if err != nil { log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "client_creation_failed")) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } @@ -1352,6 +1762,8 @@ func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) if err != nil { log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "reg_key_not_found")) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } @@ -1360,6 +1772,8 @@ func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev) if err != nil { log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "signed_client_creation_failed")) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } @@ -1367,34 +1781,44 @@ func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) if err != nil || resp.StatusCode != http.StatusNoContent { log.Printf("failed to update tracking branch: %s", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "hidden_ref_update_failed")) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) + span.SetAttributes(attribute.String("hidden_ref", hiddenRef)) + comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) if err != nil { log.Printf("failed to compare branches: %s", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "compare_failed")) s.pages.Notice(w, "resubmit-error", err.Error()) return } sourceRev := comparison.Rev2 patch := comparison.Patch + span.SetAttributes(attribute.String("source_rev", sourceRev)) if err = validateResubmittedPatch(pull, patch); err != nil { + span.SetAttributes(attribute.String("error", "invalid_patch")) s.pages.Notice(w, "resubmit-error", err.Error()) return } if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { + span.SetAttributes(attribute.String("error", "no_changes")) s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") return } - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start tx") + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } @@ -1403,14 +1827,17 @@ func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { err = db.ResubmitPull(tx, pull, patch, sourceRev) if err != nil { log.Println("failed to create pull request", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") return } - client, _ := s.auth.AuthorizedClient(r) + client, _ := s.auth.AuthorizedClient(r.WithContext(ctx)) - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) + ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) if err != nil { // failed to get record + span.RecordError(err) + span.SetAttributes(attribute.String("error", "record_not_found")) s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") return } @@ -1420,7 +1847,7 @@ func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { Branch: pull.PullSource.Branch, Repo: &repoAt, } - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoPullNSID, Repo: user.Did, Rkey: pull.Rkey, @@ -1438,12 +1865,14 @@ func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println("failed to update record", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") return } if err = tx.Commit(); err != nil { log.Println("failed to commit transaction", err) + span.RecordError(err) s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") return } @@ -1470,30 +1899,46 @@ func validateResubmittedPatch(pull *db.Pull, patch string) error { } func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "MergePull") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to resolve repo:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "resolve_repo_failed")) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") + span.SetAttributes(attribute.String("error", "pull_not_in_context")) s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.String("pull.owner", pull.OwnerDid), + attribute.String("target_branch", pull.TargetBranch), + ) + secret, err := db.GetRegistrationKey(s.db, f.Knot) if err != nil { log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "reg_key_not_found")) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") return } - ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) + ident, err := s.resolver.ResolveIdent(ctx, pull.OwnerDid) if err != nil { log.Printf("resolving identity: %s", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "resolve_identity_failed")) w.WriteHeader(http.StatusNotFound) return } @@ -1501,11 +1946,15 @@ func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) if err != nil { log.Printf("failed to get primary email: %s", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "get_email_failed")) } ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) if err != nil { log.Printf("failed to create signed client for %s: %s", f.Knot, err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "client_creation_failed")) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") return } @@ -1514,55 +1963,84 @@ func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) if err != nil { log.Printf("failed to merge pull request: %s", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "merge_failed")) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") return } + span.SetAttributes(attribute.Int("response.status", resp.StatusCode)) + if resp.StatusCode == http.StatusOK { err := db.MergePull(s.db, f.RepoAt, pull.PullId) if err != nil { log.Printf("failed to update pull request status in database: %s", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "db_update_failed")) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") return } s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) } else { log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) + span.SetAttributes(attribute.String("error", "non_ok_response")) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") } } func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) + ctx, span := s.t.TraceStart(r.Context(), "ClosePull") + defer span.End() - f, err := s.fullyResolvedRepo(r) + user := s.auth.GetUser(r.WithContext(ctx)) + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("malformed middleware") + span.RecordError(err) + span.SetAttributes(attribute.String("error", "resolve_repo_failed")) return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") + span.SetAttributes(attribute.String("error", "pull_not_in_context")) s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.String("pull.owner", pull.OwnerDid), + attribute.String("user.did", user.Did), + ) + // auth filter: only owner or collaborators can close roles := RolesInRepo(s, user, f) isCollaborator := roles.IsCollaborator() isPullAuthor := user.Did == pull.OwnerDid isCloseAllowed := isCollaborator || isPullAuthor + + span.SetAttributes( + attribute.Bool("is_collaborator", isCollaborator), + attribute.Bool("is_pull_author", isPullAuthor), + attribute.Bool("is_close_allowed", isCloseAllowed), + ) + if !isCloseAllowed { log.Println("failed to close pull") + span.SetAttributes(attribute.String("error", "unauthorized")) s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") return } // Start a transaction - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start transaction", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "transaction_start_failed")) s.pages.Notice(w, "pull-close", "Failed to close pull.") return } @@ -1571,6 +2049,8 @@ func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { err = db.ClosePull(tx, f.RepoAt, pull.PullId) if err != nil { log.Println("failed to close pull", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "db_close_failed")) s.pages.Notice(w, "pull-close", "Failed to close pull.") return } @@ -1578,6 +2058,8 @@ func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { // Commit the transaction if err = tx.Commit(); err != nil { log.Println("failed to commit transaction", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "transaction_commit_failed")) s.pages.Notice(w, "pull-close", "Failed to close pull.") return } @@ -1587,37 +2069,59 @@ func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { } func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { - user := s.auth.GetUser(r) + ctx, span := s.t.TraceStart(r.Context(), "ReopenPull") + defer span.End() + + user := s.auth.GetUser(r.WithContext(ctx)) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to resolve repo", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "resolve_repo_failed")) s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") return } - pull, ok := r.Context().Value("pull").(*db.Pull) + pull, ok := ctx.Value("pull").(*db.Pull) if !ok { log.Println("failed to get pull") + span.SetAttributes(attribute.String("error", "pull_not_in_context")) s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } - // auth filter: only owner or collaborators can close + span.SetAttributes( + attribute.Int("pull.id", pull.PullId), + attribute.String("pull.owner", pull.OwnerDid), + attribute.String("user.did", user.Did), + ) + + // auth filter: only owner or collaborators can reopen roles := RolesInRepo(s, user, f) isCollaborator := roles.IsCollaborator() isPullAuthor := user.Did == pull.OwnerDid - isCloseAllowed := isCollaborator || isPullAuthor - if !isCloseAllowed { - log.Println("failed to close pull") - s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") + isReopenAllowed := isCollaborator || isPullAuthor + + span.SetAttributes( + attribute.Bool("is_collaborator", isCollaborator), + attribute.Bool("is_pull_author", isPullAuthor), + attribute.Bool("is_reopen_allowed", isReopenAllowed), + ) + + if !isReopenAllowed { + log.Println("failed to reopen pull") + span.SetAttributes(attribute.String("error", "unauthorized")) + s.pages.Notice(w, "pull-close", "You are unauthorized to reopen this pull.") return } // Start a transaction - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start transaction", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "transaction_start_failed")) s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") return } @@ -1626,6 +2130,8 @@ func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { err = db.ReopenPull(tx, f.RepoAt, pull.PullId) if err != nil { log.Println("failed to reopen pull", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "db_reopen_failed")) s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") return } @@ -1633,6 +2139,8 @@ func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { // Commit the transaction if err = tx.Commit(); err != nil { log.Println("failed to commit transaction", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "transaction_commit_failed")) s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") return } diff --git a/appview/state/repo.go b/appview/state/repo.go index 6a85925..02a76b6 100644 --- a/appview/state/repo.go +++ b/appview/state/repo.go @@ -16,6 +16,8 @@ import ( "strings" "time" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/auth" @@ -38,16 +40,23 @@ import ( ) func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "RepoIndex") + defer span.End() + ref := chi.URLParam(r, "ref") - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to fully resolve repo", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to fully resolve repo") return } us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Printf("failed to create unsigned client for %s", f.Knot) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create unsigned client") s.pages.Error503(w) return } @@ -56,6 +65,8 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { if err != nil { s.pages.Error503(w) log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } defer resp.Body.Close() @@ -63,6 +74,8 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error reading response body") return } @@ -70,6 +83,8 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &result) if err != nil { log.Printf("Error unmarshalling response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error unmarshalling response body") return } @@ -112,6 +127,13 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { tagCount := len(result.Tags) fileCount := len(result.Files) + span.SetAttributes( + attribute.Int("commits.count", commitCount), + attribute.Int("branches.count", branchCount), + attribute.Int("tags.count", tagCount), + attribute.Int("files.count", fileCount), + ) + commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] @@ -122,7 +144,7 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { user := s.auth.GetUser(r) s.pages.RepoIndexPage(w, pages.RepoIndexParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), TagMap: tagMap, RepoIndexResponse: result, CommitsTrunc: commitsTrunc, @@ -134,9 +156,14 @@ func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoLog") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to fully resolve repo", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to fully resolve repo") return } @@ -149,22 +176,29 @@ func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { } ref := chi.URLParam(r, "ref") + span.SetAttributes(attribute.Int("page", page), attribute.String("ref", ref)) us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Println("failed to create unsigned client", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create unsigned client") return } resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("error reading response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error reading response body") return } @@ -172,12 +206,18 @@ func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &repolog) if err != nil { log.Println("failed to parse json response", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse json response") return } + span.SetAttributes(attribute.Int("commits.count", len(repolog.Commits))) + result, err := us.Tags(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver for tags") return } @@ -190,11 +230,13 @@ func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { tagMap[hash] = append(tagMap[hash], tag.Name) } + span.SetAttributes(attribute.Int("tags.count", len(result.Tags))) + user := s.auth.GetUser(r) s.pages.RepoLog(w, pages.RepoLogParams{ LoggedInUser: user, TagMap: tagMap, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), RepoLogResponse: repolog, EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), }) @@ -202,7 +244,10 @@ func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoDescriptionEdit") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) w.WriteHeader(http.StatusBadRequest) @@ -211,15 +256,20 @@ func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { user := s.auth.GetUser(r) s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), }) return } func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoDescription") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") w.WriteHeader(http.StatusBadRequest) return } @@ -228,27 +278,33 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { rkey := repoAt.RecordKey().String() if rkey == "" { log.Println("invalid aturi for repo", err) + span.RecordError(err) + span.SetStatus(codes.Error, "invalid aturi for repo") w.WriteHeader(http.StatusInternalServerError) return } user := s.auth.GetUser(r) + span.SetAttributes(attribute.String("method", r.Method)) switch r.Method { case http.MethodGet: s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), }) return case http.MethodPut: user := s.auth.GetUser(r) newDescription := r.FormValue("description") + span.SetAttributes(attribute.String("description", newDescription)) client, _ := s.auth.AuthorizedClient(r) // optimistic update - err = db.UpdateDescription(s.db, string(repoAt), newDescription) + err = db.UpdateDescription(ctx, s.db, string(repoAt), newDescription) if err != nil { - log.Println("failed to perferom update-description query", err) + log.Println("failed to perform update-description query", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to update description in database") s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") return } @@ -256,13 +312,16 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field // // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey) + ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoNSID, user.Did, rkey) if err != nil { // failed to get record + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get record from PDS") s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") return } - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: rkey, @@ -279,13 +338,15 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { }) if err != nil { - log.Println("failed to perferom update-description query", err) + log.Println("failed to perform update-description query", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to put record to PDS") // failed to get record s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") return } - newRepoInfo := f.RepoInfo(s, user) + newRepoInfo := f.RepoInfo(ctx, s, user) newRepoInfo.Description = newDescription s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ @@ -296,9 +357,14 @@ func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoCommit") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to fully resolve repo", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to fully resolve repo") return } ref := chi.URLParam(r, "ref") @@ -307,20 +373,30 @@ func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { protocol = "https" } + span.SetAttributes(attribute.String("ref", ref), attribute.String("protocol", protocol)) + if !plumbing.IsHash(ref) { + span.SetAttributes(attribute.Bool("invalid_hash", true)) s.pages.Error404(w) return } - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) + requestURL := fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref) + span.SetAttributes(attribute.String("request_url", requestURL)) + + resp, err := http.Get(requestURL) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error reading response body") return } @@ -328,13 +404,15 @@ func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse response") return } user := s.auth.GetUser(r) s.pages.RepoCommit(w, pages.RepoCommitParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), RepoCommitResponse: result, EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), }) @@ -342,9 +420,14 @@ func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoTree") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to fully resolve repo", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to fully resolve repo") return } @@ -354,15 +437,29 @@ func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { if !s.config.Dev { protocol = "https" } - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) + + span.SetAttributes( + attribute.String("ref", ref), + attribute.String("tree_path", treePath), + attribute.String("protocol", protocol), + ) + + requestURL := fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath) + span.SetAttributes(attribute.String("request_url", requestURL)) + + resp, err := http.Get(requestURL) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error reading response body") return } @@ -370,13 +467,17 @@ func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse response") return } // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, // so we can safely redirect to the "parent" (which is the same file). if len(result.Files) == 0 && result.Parent == treePath { - http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) + redirectURL := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent) + span.SetAttributes(attribute.String("redirect_url", redirectURL)) + http.Redirect(w, r, redirectURL, http.StatusFound) return } @@ -398,37 +499,52 @@ func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { BreadCrumbs: breadcrumbs, BaseTreeLink: baseTreeLink, BaseBlobLink: baseBlobLink, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), RepoTreeResponse: result, }) return } func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoTags") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Println("failed to create unsigned client", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create unsigned client") return } result, err := us.Tags(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } + span.SetAttributes(attribute.Int("tags.count", len(result.Tags))) + artifacts, err := db.GetArtifact(s.db, db.Filter("repo_at", f.RepoAt)) if err != nil { log.Println("failed grab artifacts", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to grab artifacts") return } + span.SetAttributes(attribute.Int("artifacts.count", len(artifacts))) + // convert artifacts to map for easy UI building artifactMap := make(map[plumbing.Hash][]db.Artifact) for _, a := range artifacts { @@ -451,10 +567,12 @@ func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { } } + span.SetAttributes(attribute.Int("dangling_artifacts.count", len(danglingArtifacts))) + user := s.auth.GetUser(r) s.pages.RepoTags(w, pages.RepoTagsParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), RepoTagsResponse: *result, ArtifactMap: artifactMap, DanglingArtifacts: danglingArtifacts, @@ -463,27 +581,38 @@ func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoBranches") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Println("failed to create unsigned client", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create unsigned client") return } resp, err := us.Branches(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error reading response body") return } @@ -491,9 +620,13 @@ func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse response") return } + span.SetAttributes(attribute.Int("branches.count", len(result.Branches))) + slices.SortFunc(result.Branches, func(a, b types.Branch) int { if a.IsDefault { return -1 @@ -514,16 +647,21 @@ func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { user := s.auth.GetUser(r) s.pages.RepoBranches(w, pages.RepoBranchesParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), RepoBranchesResponse: result, }) return } func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoBlob") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } @@ -533,15 +671,29 @@ func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { if !s.config.Dev { protocol = "https" } - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) + + span.SetAttributes( + attribute.String("ref", ref), + attribute.String("file_path", filePath), + attribute.String("protocol", protocol), + ) + + requestURL := fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) + span.SetAttributes(attribute.String("request_url", requestURL)) + + resp, err := http.Get(requestURL) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error reading response body") return } @@ -549,6 +701,8 @@ func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse response") return } @@ -568,10 +722,16 @@ func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { showRendered = r.URL.Query().Get("code") != "true" } + span.SetAttributes( + attribute.Bool("is_binary", result.IsBinary), + attribute.Bool("show_rendered", showRendered), + attribute.Bool("render_toggle", renderToggle), + ) + user := s.auth.GetUser(r) s.pages.RepoBlob(w, pages.RepoBlobParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), RepoBlobResponse: result, BreadCrumbs: breadcrumbs, ShowRendered: showRendered, @@ -581,9 +741,14 @@ func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoBlobRaw") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } @@ -594,15 +759,29 @@ func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { if !s.config.Dev { protocol = "https" } - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) + + span.SetAttributes( + attribute.String("ref", ref), + attribute.String("file_path", filePath), + attribute.String("protocol", protocol), + ) + + requestURL := fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath) + span.SetAttributes(attribute.String("request_url", requestURL)) + + resp, err := http.Get(requestURL) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reach knotserver") return } body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "error reading response body") return } @@ -610,9 +789,13 @@ func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse response") return } + span.SetAttributes(attribute.Bool("is_binary", result.IsBinary)) + if result.IsBinary { w.Header().Set("Content-Type", "application/octet-stream") w.Write(body) @@ -625,53 +808,76 @@ func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { } func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "AddCollaborator") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } collaborator := r.FormValue("collaborator") if collaborator == "" { + span.SetAttributes(attribute.String("error", "malformed_form")) http.Error(w, "malformed form", http.StatusBadRequest) return } - collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) + span.SetAttributes(attribute.String("collaborator", collaborator)) + + collaboratorIdent, err := s.resolver.ResolveIdent(ctx, collaborator) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve collaborator") w.Write([]byte("failed to resolve collaborator did to a handle")) return } log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) + span.SetAttributes( + attribute.String("collaborator_did", collaboratorIdent.DID.String()), + attribute.String("collaborator_handle", collaboratorIdent.Handle.String()), + ) // TODO: create an atproto record for this secret, err := db.GetRegistrationKey(s.db, f.Knot) if err != nil { log.Printf("no key found for domain %s: %s\n", f.Knot, err) + span.RecordError(err) + span.SetStatus(codes.Error, "no key found for domain") return } ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) if err != nil { log.Println("failed to create client to ", f.Knot) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create signed client") return } ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) if err != nil { log.Printf("failed to make request to %s: %s", f.Knot, err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to make request to knotserver") return } if ksResp.StatusCode != http.StatusNoContent { + span.SetAttributes(attribute.Int("status_code", ksResp.StatusCode)) w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) return } - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start tx") + span.RecordError(err) + span.SetStatus(codes.Error, "failed to start transaction") w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) return } @@ -685,12 +891,16 @@ func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to add collaborator to enforcer") w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) return } - err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) + err = db.AddCollaborator(ctx, s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to add collaborator to database") w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) return } @@ -698,6 +908,8 @@ func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { err = tx.Commit() if err != nil { log.Println("failed to commit changes", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to commit transaction") http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -705,65 +917,91 @@ func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { err = s.enforcer.E.SavePolicy() if err != nil { log.Println("failed to update ACLs", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to save enforcer policy") http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) - } func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "DeleteRepo") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } + span.SetAttributes( + attribute.String("repo_name", f.RepoName), + attribute.String("knot", f.Knot), + attribute.String("owner_did", f.OwnerDid()), + ) + // remove record from pds xrpcClient, _ := s.auth.AuthorizedClient(r) repoRkey := f.RepoAt.RecordKey().String() - _, err = comatproto.RepoDeleteRecord(r.Context(), xrpcClient, &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(ctx, xrpcClient, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: repoRkey, }) if err != nil { log.Printf("failed to delete record: %s", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to delete record from PDS") s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") return } log.Println("removed repo record ", f.RepoAt.String()) + span.SetAttributes(attribute.String("repo_at", f.RepoAt.String())) secret, err := db.GetRegistrationKey(s.db, f.Knot) if err != nil { log.Printf("no key found for domain %s: %s\n", f.Knot, err) + span.RecordError(err) + span.SetStatus(codes.Error, "no key found for domain") return } ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) if err != nil { log.Println("failed to create client to ", f.Knot) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create client") return } ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) if err != nil { log.Printf("failed to make request to %s: %s", f.Knot, err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to make request to knotserver") return } + span.SetAttributes(attribute.Int("ks_status_code", ksResp.StatusCode)) if ksResp.StatusCode != http.StatusNoContent { log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) + span.SetAttributes(attribute.Bool("knot_remove_failed", true)) } else { log.Println("removed repo from knot ", f.Knot) + span.SetAttributes(attribute.Bool("knot_remove_success", true)) } - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println("failed to start tx") + span.RecordError(err) + span.SetStatus(codes.Error, "failed to start transaction") w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) return } @@ -772,15 +1010,20 @@ func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { err = s.enforcer.E.LoadPolicy() if err != nil { log.Println("failed to rollback policies") + span.RecordError(err) } }() // remove collaborator RBAC repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get collaborators") s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") return } + span.SetAttributes(attribute.Int("collaborators.count", len(repoCollaborators))) + for _, c := range repoCollaborators { did := c[0] s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) @@ -790,13 +1033,17 @@ func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { // remove repo RBAC err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to remove repo RBAC") s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") return } // remove repo from db - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) + err = db.RemoveRepo(ctx, tx, f.OwnerDid(), f.RepoName) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to remove repo from db") s.pages.Notice(w, "settings-delete", "Failed to update appview") return } @@ -805,6 +1052,8 @@ func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { err = tx.Commit() if err != nil { log.Println("failed to commit changes", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to commit transaction") http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -812,6 +1061,8 @@ func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { err = s.enforcer.E.SavePolicy() if err != nil { log.Println("failed to update ACLs", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to save policy") http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -820,37 +1071,59 @@ func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { } func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "SetDefaultBranch") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } branch := r.FormValue("branch") if branch == "" { + span.SetAttributes(attribute.Bool("malformed_form", true)) + span.SetStatus(codes.Error, "malformed form") http.Error(w, "malformed form", http.StatusBadRequest) return } + span.SetAttributes( + attribute.String("branch", branch), + attribute.String("repo_name", f.RepoName), + attribute.String("knot", f.Knot), + attribute.String("owner_did", f.OwnerDid()), + ) + secret, err := db.GetRegistrationKey(s.db, f.Knot) if err != nil { log.Printf("no key found for domain %s: %s\n", f.Knot, err) + span.RecordError(err) + span.SetStatus(codes.Error, "no key found for domain") return } ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) if err != nil { log.Println("failed to create client to ", f.Knot) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create client") return } ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) if err != nil { log.Printf("failed to make request to %s: %s", f.Knot, err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to make request to knotserver") return } + span.SetAttributes(attribute.Int("ks_status_code", ksResp.StatusCode)) if ksResp.StatusCode != http.StatusNoContent { + span.SetStatus(codes.Error, "failed to set default branch") s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") return } @@ -859,20 +1132,35 @@ func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { - f, err := s.fullyResolvedRepo(r) + ctx, span := s.t.TraceStart(r.Context(), "RepoSettings") + defer span.End() + + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get repo and knot") return } + span.SetAttributes( + attribute.String("repo_name", f.RepoName), + attribute.String("knot", f.Knot), + attribute.String("owner_did", f.OwnerDid()), + attribute.String("method", r.Method), + ) + switch r.Method { case http.MethodGet: // for now, this is just pubkeys user := s.auth.GetUser(r) - repoCollaborators, err := f.Collaborators(r.Context(), s) + repoCollaborators, err := f.Collaborators(ctx, s) if err != nil { log.Println("failed to get collaborators", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_get_collaborators")) } + span.SetAttributes(attribute.Int("collaborators.count", len(repoCollaborators))) isCollaboratorInviteAllowed := false if user != nil { @@ -881,31 +1169,41 @@ func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { isCollaboratorInviteAllowed = true } } + span.SetAttributes(attribute.Bool("invite_allowed", isCollaboratorInviteAllowed)) var branchNames []string var defaultBranch string us, err := NewUnsignedClient(f.Knot, s.config.Dev) if err != nil { log.Println("failed to create unsigned client", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_create_unsigned_client")) } else { resp, err := us.Branches(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver_for_branches")) } else { defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("Error reading response body: %v", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_read_branches_response")) } else { var result types.RepoBranchesResponse err = json.Unmarshal(body, &result) if err != nil { log.Println("failed to parse response:", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_parse_branches_response")) } else { for _, branch := range result.Branches { branchNames = append(branchNames, branch.Name) } + span.SetAttributes(attribute.Int("branches.count", len(branchNames))) } } } @@ -913,13 +1211,16 @@ func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName) if err != nil { log.Println("failed to reach knotserver", err) + span.RecordError(err) + span.SetAttributes(attribute.String("error", "failed_to_reach_knotserver_for_default_branch")) } else { defaultBranch = defaultBranchResp.Branch + span.SetAttributes(attribute.String("default_branch", defaultBranch)) } } s.pages.RepoSettings(w, pages.RepoSettingsParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Collaborators: repoCollaborators, IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, Branches: branchNames, @@ -1008,65 +1309,96 @@ func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]page return collaborators, nil } -func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) repoinfo.RepoInfo { +func (f *FullyResolvedRepo) RepoInfo(ctx context.Context, s *State, u *auth.User) repoinfo.RepoInfo { + ctx, span := s.t.TraceStart(ctx, "RepoInfo") + defer span.End() + isStarred := false if u != nil { isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) + span.SetAttributes(attribute.Bool("is_starred", isStarred)) } starCount, err := db.GetStarCount(s.db, f.RepoAt) if err != nil { log.Println("failed to get star count for ", f.RepoAt) + span.RecordError(err) } + issueCount, err := db.GetIssueCount(s.db, f.RepoAt) if err != nil { log.Println("failed to get issue count for ", f.RepoAt) + span.RecordError(err) } + pullCount, err := db.GetPullCount(s.db, f.RepoAt) if err != nil { log.Println("failed to get issue count for ", f.RepoAt) + span.RecordError(err) } - source, err := db.GetRepoSource(s.db, f.RepoAt) + + span.SetAttributes( + attribute.Int("stats.stars", starCount), + attribute.Int("stats.issues.open", issueCount.Open), + attribute.Int("stats.issues.closed", issueCount.Closed), + attribute.Int("stats.pulls.open", pullCount.Open), + attribute.Int("stats.pulls.closed", pullCount.Closed), + attribute.Int("stats.pulls.merged", pullCount.Merged), + ) + + source, err := db.GetRepoSource(ctx, s.db, f.RepoAt) if errors.Is(err, sql.ErrNoRows) { source = "" } else if err != nil { log.Println("failed to get repo source for ", f.RepoAt, err) + span.RecordError(err) } var sourceRepo *db.Repo if source != "" { - sourceRepo, err = db.GetRepoByAtUri(s.db, source) + span.SetAttributes(attribute.String("source", source)) + sourceRepo, err = db.GetRepoByAtUri(ctx, s.db, source) if err != nil { log.Println("failed to get repo by at uri", err) + span.RecordError(err) } } var sourceHandle *identity.Identity if sourceRepo != nil { - sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) + sourceHandle, err = s.resolver.ResolveIdent(ctx, sourceRepo.Did) if err != nil { log.Println("failed to resolve source repo", err) + span.RecordError(err) + } else if sourceHandle != nil { + span.SetAttributes(attribute.String("source_handle", sourceHandle.Handle.String())) } } knot := f.Knot + span.SetAttributes(attribute.String("knot", knot)) + var disableFork bool us, err := NewUnsignedClient(knot, s.config.Dev) if err != nil { log.Printf("failed to create unsigned client for %s: %v", knot, err) + span.RecordError(err) } else { resp, err := us.Branches(f.OwnerDid(), f.RepoName) if err != nil { log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) + span.RecordError(err) } else { defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { log.Printf("error reading branch response body: %v", err) + span.RecordError(err) } else { var branchesResp types.RepoBranchesResponse if err := json.Unmarshal(body, &branchesResp); err != nil { log.Printf("error parsing branch response: %v", err) + span.RecordError(err) } else { disableFork = false } @@ -1074,6 +1406,10 @@ func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) repoinfo.RepoInfo { if len(branchesResp.Branches) == 0 { disableFork = true } + span.SetAttributes( + attribute.Int("branches.count", len(branchesResp.Branches)), + attribute.Bool("disable_fork", disableFork), + ) } } } @@ -1105,10 +1441,15 @@ func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) repoinfo.RepoInfo { } func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "RepoSingleIssue") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } @@ -1117,26 +1458,40 @@ func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse issue id") return } - issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) + span.SetAttributes(attribute.Int("issue_id", issueIdInt)) + + issue, comments, err := db.GetIssueWithComments(ctx, s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue and comments", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue and comments") s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } - issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) + span.SetAttributes( + attribute.Int("comments.count", len(comments)), + attribute.String("issue.title", issue.Title), + attribute.String("issue.owner_did", issue.OwnerDid), + ) + + issueOwnerIdent, err := s.resolver.ResolveIdent(ctx, issue.OwnerDid) if err != nil { log.Println("failed to resolve issue owner", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve issue owner") } identsToResolve := make([]string, len(comments)) for i, comment := range comments { identsToResolve[i] = comment.OwnerDid } - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -1148,21 +1503,25 @@ func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Issue: *issue, Comments: comments, IssueOwnerHandle: issueOwnerIdent.Handle.String(), DidHandleMap: didHandleMap, }) - } func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "CloseIssue") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } @@ -1171,32 +1530,44 @@ func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse issue id") return } - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) + span.SetAttributes(attribute.Int("issue_id", issueIdInt)) + + issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue") s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } - collaborators, err := f.Collaborators(r.Context(), s) + collaborators, err := f.Collaborators(ctx, s) if err != nil { log.Println("failed to fetch repo collaborators: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to fetch repo collaborators") } isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { return user.Did == collab.Did }) isIssueOwner := user.Did == issue.OwnerDid + span.SetAttributes( + attribute.Bool("is_collaborator", isCollaborator), + attribute.Bool("is_issue_owner", isIssueOwner), + ) + // TODO: make this more granular if isIssueOwner || isCollaborator { - closed := tangled.RepoIssueStateClosed client, _ := s.auth.AuthorizedClient(r) - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueStateNSID, Repo: user.Did, Rkey: appview.TID(), @@ -1210,6 +1581,8 @@ func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { if err != nil { log.Println("failed to update issue state", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to update issue state in PDS") s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } @@ -1217,6 +1590,8 @@ func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to close issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to close issue in database") s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } @@ -1225,16 +1600,22 @@ func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { return } else { log.Println("user is not permitted to close issue") + span.SetAttributes(attribute.Bool("permission_denied", true)) http.Error(w, "for biden", http.StatusUnauthorized) return } } func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "ReopenIssue") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } @@ -1243,29 +1624,44 @@ func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse issue id") return } - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) + span.SetAttributes(attribute.Int("issue_id", issueIdInt)) + + issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue") s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") return } - collaborators, err := f.Collaborators(r.Context(), s) + collaborators, err := f.Collaborators(ctx, s) if err != nil { log.Println("failed to fetch repo collaborators: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to fetch repo collaborators") } isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { return user.Did == collab.Did }) isIssueOwner := user.Did == issue.OwnerDid + span.SetAttributes( + attribute.Bool("is_collaborator", isCollaborator), + attribute.Bool("is_issue_owner", isIssueOwner), + ) + if isCollaborator || isIssueOwner { err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to reopen issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to reopen issue") s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") return } @@ -1273,16 +1669,22 @@ func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { return } else { log.Println("user is not the owner of the repo") + span.SetAttributes(attribute.Bool("permission_denied", true)) http.Error(w, "forbidden", http.StatusUnauthorized) return } } func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "NewIssueComment") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } @@ -1291,13 +1693,21 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse issue id") return } + span.SetAttributes( + attribute.Int("issue_id", issueIdInt), + attribute.String("method", r.Method), + ) + switch r.Method { case http.MethodPost: body := r.FormValue("body") if body == "" { + span.SetAttributes(attribute.Bool("missing_body", true)) s.pages.Notice(w, "issue", "Body is required") return } @@ -1305,6 +1715,11 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { commentId := mathrand.IntN(1000000) rkey := appview.TID() + span.SetAttributes( + attribute.Int("comment_id", commentId), + attribute.String("rkey", rkey), + ) + err := db.NewIssueComment(s.db, &db.Comment{ OwnerDid: user.Did, RepoAt: f.RepoAt, @@ -1315,6 +1730,8 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println("failed to create comment", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create comment in database") s.pages.Notice(w, "issue-comment", "Failed to create comment.") return } @@ -1325,13 +1742,17 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue at", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue at") s.pages.Notice(w, "issue-comment", "Failed to create comment.") return } + span.SetAttributes(attribute.String("issue_at", issueAt)) + atUri := f.RepoAt.String() client, _ := s.auth.AuthorizedClient(r) - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: rkey, @@ -1348,6 +1769,8 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println("failed to create comment", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create comment in PDS") s.pages.Notice(w, "issue-comment", "Failed to create comment.") return } @@ -1358,10 +1781,15 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { } func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "IssueComment") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } @@ -1370,6 +1798,8 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse issue id") return } @@ -1378,12 +1808,21 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse comment id") return } - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) + span.SetAttributes( + attribute.Int("issue_id", issueIdInt), + attribute.Int("comment_id", commentIdInt), + ) + + issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue") s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } @@ -1391,12 +1830,16 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get comment") return } - identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) + identity, err := s.resolver.ResolveIdent(ctx, comment.OwnerDid) if err != nil { log.Println("failed to resolve did") + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve did") return } @@ -1409,7 +1852,7 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, @@ -1417,10 +1860,15 @@ func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { } func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "EditIssueComment") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } @@ -1429,6 +1877,8 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse issue id") return } @@ -1437,12 +1887,22 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse comment id") return } - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) + span.SetAttributes( + attribute.Int("issue_id", issueIdInt), + attribute.Int("comment_id", commentIdInt), + attribute.String("method", r.Method), + ) + + issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue") s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } @@ -1450,11 +1910,14 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get comment") return } if comment.OwnerDid != user.Did { http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) + span.SetAttributes(attribute.Bool("permission_denied", true)) return } @@ -1462,7 +1925,7 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { case http.MethodGet: s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Issue: issue, Comment: comment, }) @@ -1472,11 +1935,18 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { client, _ := s.auth.AuthorizedClient(r) rkey := comment.Rkey + span.SetAttributes( + attribute.String("new_body", newBody), + attribute.String("rkey", rkey), + ) + // optimistic update edited := time.Now() err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) if err != nil { log.Println("failed to perferom update-description query", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to edit comment in database") s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") return } @@ -1484,10 +1954,12 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { // rkey is optional, it was introduced later if comment.Rkey != "" { // update the record on pds - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey) + ex, err := comatproto.RepoGetRecord(ctx, client, "", tangled.RepoIssueCommentNSID, user.Did, rkey) if err != nil { // failed to get record log.Println(err, rkey) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get record from PDS") s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") return } @@ -1499,7 +1971,7 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { createdAt := record["createdAt"].(string) commentIdInt64 := int64(commentIdInt) - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueCommentNSID, Repo: user.Did, Rkey: rkey, @@ -1517,6 +1989,8 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println(err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to put record to PDS") } } @@ -1530,22 +2004,25 @@ func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { // return new comment body with htmx s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, }) return - } - } func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "DeleteIssueComment") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } @@ -1554,12 +2031,16 @@ func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad issue id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse issue id") return } - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) + issue, err := db.GetIssue(ctx, s.db, f.RepoAt, issueIdInt) if err != nil { log.Println("failed to get issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue") s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") return } @@ -1569,22 +2050,33 @@ func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) log.Println("failed to parse issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to parse comment id") return } + span.SetAttributes( + attribute.Int("issue_id", issueIdInt), + attribute.Int("comment_id", commentIdInt), + ) + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { http.Error(w, "bad comment id", http.StatusBadRequest) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get comment") return } if comment.OwnerDid != user.Did { http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) + span.SetAttributes(attribute.Bool("permission_denied", true)) return } if comment.Deleted != nil { http.Error(w, "comment already deleted", http.StatusBadRequest) + span.SetAttributes(attribute.Bool("already_deleted", true)) return } @@ -1593,6 +2085,8 @@ func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) if err != nil { log.Println("failed to delete comment") + span.RecordError(err) + span.SetStatus(codes.Error, "failed to delete comment in database") s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") return } @@ -1600,13 +2094,15 @@ func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { // delete from pds if comment.Rkey != "" { client, _ := s.auth.AuthorizedClient(r) - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ + _, err = comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{ Collection: tangled.GraphFollowNSID, Repo: user.Did, Rkey: comment.Rkey, }) if err != nil { log.Println(err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to delete record from PDS") } } @@ -1620,7 +2116,7 @@ func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { // htmx fragment of comment after deletion s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), DidHandleMap: didHandleMap, Issue: issue, Comment: comment, @@ -1629,6 +2125,9 @@ func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { } func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "RepoIssues") + defer span.End() + params := r.URL.Query() state := params.Get("state") isOpen := true @@ -1641,31 +2140,43 @@ func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { isOpen = true } + span.SetAttributes( + attribute.Bool("is_open", isOpen), + attribute.String("state_param", state), + ) + page, ok := r.Context().Value("page").(pagination.Page) if !ok { log.Println("failed to get page") + span.SetAttributes(attribute.Bool("page_not_found", true)) page = pagination.FirstPage() } user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } - issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) + issues, err := db.GetIssues(ctx, s.db, f.RepoAt, isOpen, page) if err != nil { log.Println("failed to get issues", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issues") s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") return } + span.SetAttributes(attribute.Int("issues.count", len(issues))) + identsToResolve := make([]string, len(issues)) for i, issue := range issues { identsToResolve[i] = issue.OwnerDid } - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, identsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -1677,7 +2188,7 @@ func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { s.pages.RepoIssues(w, pages.RepoIssuesParams{ LoggedInUser: s.auth.GetUser(r), - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), Issues: issues, DidHandleMap: didHandleMap, FilteringByOpen: isOpen, @@ -1687,31 +2198,46 @@ func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { } func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "NewIssue") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Println("failed to get repo and knot", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve repo") return } + span.SetAttributes(attribute.String("method", r.Method)) + switch r.Method { case http.MethodGet: s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ LoggedInUser: user, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), }) case http.MethodPost: title := r.FormValue("title") body := r.FormValue("body") + span.SetAttributes( + attribute.String("title", title), + attribute.String("body_length", fmt.Sprintf("%d", len(body))), + ) + if title == "" || body == "" { + span.SetAttributes(attribute.Bool("form_validation_failed", true)) s.pages.Notice(w, "issues", "Title and body are required") return } - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to begin transaction") s.pages.Notice(w, "issues", "Failed to create issue, try again later") return } @@ -1724,6 +2250,8 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println("failed to create issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create issue in database") s.pages.Notice(w, "issues", "Failed to create issue.") return } @@ -1731,16 +2259,23 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { issueId, err := db.GetIssueId(s.db, f.RepoAt) if err != nil { log.Println("failed to get issue id", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get issue id") s.pages.Notice(w, "issues", "Failed to create issue.") return } + span.SetAttributes(attribute.Int("issue_id", issueId)) + client, _ := s.auth.AuthorizedClient(r) atUri := f.RepoAt.String() - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + rkey := appview.TID() + span.SetAttributes(attribute.String("rkey", rkey)) + + resp, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoIssueNSID, Repo: user.Did, - Rkey: appview.TID(), + Rkey: rkey, Record: &lexutil.LexiconTypeDecoder{ Val: &tangled.RepoIssue{ Repo: atUri, @@ -1753,13 +2288,19 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Println("failed to create issue", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create issue in PDS") s.pages.Notice(w, "issues", "Failed to create issue.") return } + span.SetAttributes(attribute.String("issue_uri", resp.Uri)) + err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) if err != nil { log.Println("failed to set issue at", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to set issue URI in database") s.pages.Notice(w, "issues", "Failed to create issue.") return } @@ -1770,67 +2311,102 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { } func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "ForkRepo") + defer span.End() + user := s.auth.GetUser(r) - f, err := s.fullyResolvedRepo(r) + f, err := s.fullyResolvedRepo(r.WithContext(ctx)) if err != nil { log.Printf("failed to resolve source repo: %v", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to resolve source repo") return } + span.SetAttributes( + attribute.String("method", r.Method), + attribute.String("repo_name", f.RepoName), + attribute.String("owner_did", f.OwnerDid()), + attribute.String("knot", f.Knot), + ) + switch r.Method { case http.MethodGet: user := s.auth.GetUser(r) knots, err := s.enforcer.GetDomainsForUser(user.Did) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get domains for user") s.pages.Notice(w, "repo", "Invalid user account.") return } + span.SetAttributes(attribute.Int("knots.count", len(knots))) + s.pages.ForkRepo(w, pages.ForkRepoParams{ LoggedInUser: user, Knots: knots, - RepoInfo: f.RepoInfo(s, user), + RepoInfo: f.RepoInfo(ctx, s, user), }) case http.MethodPost: - knot := r.FormValue("knot") if knot == "" { + span.SetAttributes(attribute.Bool("missing_knot", true)) s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") return } + span.SetAttributes(attribute.String("target_knot", knot)) + ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") if err != nil || !ok { + span.SetAttributes( + attribute.Bool("permission_denied", true), + attribute.Bool("enforce_error", err != nil), + ) s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") return } forkName := fmt.Sprintf("%s", f.RepoName) + span.SetAttributes(attribute.String("fork_name", forkName)) // this check is *only* to see if the forked repo name already exists // in the user's account. - existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) + existingRepo, err := db.GetRepo(ctx, s.db, user.Did, f.RepoName) if err != nil { if errors.Is(err, sql.ErrNoRows) { // no existing repo with this name found, we can use the name as is + span.SetAttributes(attribute.Bool("repo_name_available", true)) } else { log.Println("error fetching existing repo from db", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to check for existing repo") s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") return } } else if existingRepo != nil { // repo with this name already exists, append random string forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) + span.SetAttributes( + attribute.Bool("repo_name_conflict", true), + attribute.String("adjusted_fork_name", forkName), + ) } + secret, err := db.GetRegistrationKey(s.db, knot) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to get registration key") s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) return } client, err := NewSignedClient(knot, secret, s.config.Dev) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create signed client") s.pages.Notice(w, "repo", "Failed to reach knot server.") return } @@ -1844,6 +2420,11 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) sourceAt := f.RepoAt.String() + span.SetAttributes( + attribute.String("fork_source_url", forkSourceUrl), + attribute.String("source_at", sourceAt), + ) + rkey := appview.TID() repo := &db.Repo{ Did: user.Did, @@ -1853,9 +2434,13 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { Source: sourceAt, } - tx, err := s.db.BeginTx(r.Context(), nil) + span.SetAttributes(attribute.String("rkey", rkey)) + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { log.Println(err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to begin transaction") s.pages.Notice(w, "repo", "Failed to save repository information.") return } @@ -1864,21 +2449,29 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { err = s.enforcer.E.LoadPolicy() if err != nil { log.Println("failed to rollback policies") + span.RecordError(err) } }() resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to fork repo on knot server") s.pages.Notice(w, "repo", "Failed to create repository on knot server.") return } + span.SetAttributes(attribute.Int("fork_response_status", resp.StatusCode)) + switch resp.StatusCode { case http.StatusConflict: + span.SetAttributes(attribute.Bool("name_conflict", true)) s.pages.Notice(w, "repo", "A repository with that name already exists.") return case http.StatusInternalServerError: + span.SetAttributes(attribute.Bool("server_error", true)) s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") + return case http.StatusNoContent: // continue } @@ -1886,7 +2479,7 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { xrpcClient, _ := s.auth.AuthorizedClient(r) createdAt := time.Now().Format(time.RFC3339) - atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ + atresp, err := comatproto.RepoPutRecord(ctx, xrpcClient, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: rkey, @@ -1901,15 +2494,20 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { }) if err != nil { log.Printf("failed to create record: %s", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to create record in PDS") s.pages.Notice(w, "repo", "Failed to announce repository creation.") return } log.Println("created repo record: ", atresp.Uri) + span.SetAttributes(attribute.String("repo_uri", atresp.Uri)) repo.AtUri = atresp.Uri - err = db.AddRepo(tx, repo) + err = db.AddRepo(ctx, tx, repo) if err != nil { log.Println(err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to add repo to database") s.pages.Notice(w, "repo", "Failed to save repository information.") return } @@ -1919,6 +2517,8 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { err = s.enforcer.AddRepo(user.Did, knot, p) if err != nil { log.Println(err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to set up repository permissions") s.pages.Notice(w, "repo", "Failed to set up repository permissions.") return } @@ -1926,6 +2526,8 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { err = tx.Commit() if err != nil { log.Println("failed to commit changes", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to commit transaction") http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -1933,6 +2535,8 @@ func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { err = s.enforcer.E.SavePolicy() if err != nil { log.Println("failed to update ACLs", err) + span.RecordError(err) + span.SetStatus(codes.Error, "failed to save policy") http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/appview/state/repo_util.go b/appview/state/repo_util.go index 9ca3b2d..576b336 100644 --- a/appview/state/repo_util.go +++ b/appview/state/repo_util.go @@ -12,25 +12,42 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" "github.com/go-git/go-git/v5/plumbing/object" + "go.opentelemetry.io/otel/attribute" "tangled.sh/tangled.sh/core/appview/auth" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages/repoinfo" + "tangled.sh/tangled.sh/core/telemetry" ) func (s *State) fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { + ctx := r.Context() + + attrs := telemetry.MapAttrs(map[string]string{ + "repo": chi.URLParam(r, "repo"), + "ref": chi.URLParam(r, "ref"), + }) + + ctx, span := s.t.TraceStart(ctx, "fullyResolvedRepo", attrs...) + defer span.End() + repoName := chi.URLParam(r, "repo") - knot, ok := r.Context().Value("knot").(string) + knot, ok := ctx.Value("knot").(string) if !ok { log.Println("malformed middleware") return nil, fmt.Errorf("malformed middleware") } - id, ok := r.Context().Value("resolvedId").(identity.Identity) + + span.SetAttributes(attribute.String("knot", knot)) + + id, ok := ctx.Value("resolvedId").(identity.Identity) if !ok { log.Println("malformed middleware") return nil, fmt.Errorf("malformed middleware") } - repoAt, ok := r.Context().Value("repoAt").(string) + span.SetAttributes(attribute.String("did", id.DID.String())) + + repoAt, ok := ctx.Value("repoAt").(string) if !ok { log.Println("malformed middleware") return nil, fmt.Errorf("malformed middleware") @@ -56,11 +73,12 @@ func (s *State) fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { } ref = defaultBranch.Branch + + span.SetAttributes(attribute.String("default_branch", ref)) } - // pass through values from the middleware - description, ok := r.Context().Value("repoDescription").(string) - addedAt, ok := r.Context().Value("repoAddedAt").(string) + description, ok := ctx.Value("repoDescription").(string) + addedAt, ok := ctx.Value("repoAddedAt").(string) return &FullyResolvedRepo{ Knot: knot, diff --git a/appview/state/state.go b/appview/state/state.go index 871c688..a6108b4 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -18,6 +18,7 @@ import ( lexutil "github.com/bluesky-social/indigo/lex/util" securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" + "go.opentelemetry.io/otel/attribute" "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview" "tangled.sh/tangled.sh/core/appview/auth" @@ -83,7 +84,7 @@ func Make(ctx context.Context, config *appview.Config) (*State, error) { if err != nil { return nil, fmt.Errorf("failed to create jetstream client: %w", err) } - err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper)) + err = jc.StartJetstream(ctx, appview.Ingest(wrapper)) if err != nil { return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) } @@ -198,11 +199,16 @@ func (s *State) Logout(w http.ResponseWriter, r *http.Request) { } func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "Timeline") + defer span.End() + user := s.auth.GetUser(r) + span.SetAttributes(attribute.String("user.did", user.Did)) - timeline, err := db.MakeTimeline(s.db) + timeline, err := db.MakeTimeline(ctx, s.db) if err != nil { log.Println(err) + span.RecordError(err) s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") } @@ -221,8 +227,9 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) } } + span.SetAttributes(attribute.Int("dids.to_resolve.count", len(didsToResolve))) - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) + resolvedIds := s.resolver.ResolveIdents(ctx, didsToResolve) didHandleMap := make(map[string]string) for _, identity := range resolvedIds { if !identity.Handle.IsInvalidHandle() { @@ -231,6 +238,7 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { didHandleMap[identity.DID.String()] = identity.DID.String() } } + span.SetAttributes(attribute.Int("dids.resolved.count", len(resolvedIds))) s.pages.Timeline(w, pages.TimelineParams{ LoggedInUser: user, @@ -594,14 +602,22 @@ func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { } func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { + ctx, span := s.t.TraceStart(r.Context(), "NewRepo") + defer span.End() + switch r.Method { case http.MethodGet: user := s.auth.GetUser(r) + span.SetAttributes(attribute.String("user.did", user.Did)) + span.SetAttributes(attribute.String("request.method", "GET")) + knots, err := s.enforcer.GetDomainsForUser(user.Did) if err != nil { + span.RecordError(err) s.pages.Notice(w, "repo", "Invalid user account.") return } + span.SetAttributes(attribute.Int("knots.count", len(knots))) s.pages.NewRepo(w, pages.NewRepoParams{ LoggedInUser: user, @@ -610,18 +626,22 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { case http.MethodPost: user := s.auth.GetUser(r) + span.SetAttributes(attribute.String("user.did", user.Did)) + span.SetAttributes(attribute.String("request.method", "POST")) domain := r.FormValue("domain") if domain == "" { s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") return } + span.SetAttributes(attribute.String("domain", domain)) repoName := r.FormValue("name") if repoName == "" { s.pages.Notice(w, "repo", "Repository name cannot be empty.") return } + span.SetAttributes(attribute.String("repo.name", repoName)) // Check for valid repository name (GitHub-like rules) // No spaces, only alphanumeric characters, dashes, and underscores @@ -639,29 +659,39 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { if defaultBranch == "" { defaultBranch = "main" } + span.SetAttributes(attribute.String("repo.default_branch", defaultBranch)) description := r.FormValue("description") ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") if err != nil || !ok { + if err != nil { + span.RecordError(err) + } + span.SetAttributes(attribute.Bool("permission.granted", false)) s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") return } + span.SetAttributes(attribute.Bool("permission.granted", true)) - existingRepo, err := db.GetRepo(s.db, user.Did, repoName) + existingRepo, err := db.GetRepo(ctx, s.db, user.Did, repoName) if err == nil && existingRepo != nil { + span.SetAttributes(attribute.Bool("repo.exists", true)) s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) return } + span.SetAttributes(attribute.Bool("repo.exists", false)) secret, err := db.GetRegistrationKey(s.db, domain) if err != nil { + span.RecordError(err) s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) return } client, err := NewSignedClient(domain, secret, s.config.Dev) if err != nil { + span.RecordError(err) s.pages.Notice(w, "repo", "Failed to connect to knot server.") return } @@ -675,10 +705,11 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { Description: description, } - xrpcClient, _ := s.auth.AuthorizedClient(r) + rWithCtx := r.WithContext(ctx) + xrpcClient, _ := s.auth.AuthorizedClient(rWithCtx) createdAt := time.Now().Format(time.RFC3339) - atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ + atresp, err := comatproto.RepoPutRecord(ctx, xrpcClient, &comatproto.RepoPutRecord_Input{ Collection: tangled.RepoNSID, Repo: user.Did, Rkey: rkey, @@ -691,14 +722,17 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { }}, }) if err != nil { + span.RecordError(err) log.Printf("failed to create record: %s", err) s.pages.Notice(w, "repo", "Failed to announce repository creation.") return } log.Println("created repo record: ", atresp.Uri) + span.SetAttributes(attribute.String("repo.uri", atresp.Uri)) - tx, err := s.db.BeginTx(r.Context(), nil) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { + span.RecordError(err) log.Println(err) s.pages.Notice(w, "repo", "Failed to save repository information.") return @@ -713,9 +747,11 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { resp, err := client.NewRepo(user.Did, repoName, defaultBranch) if err != nil { + span.RecordError(err) s.pages.Notice(w, "repo", "Failed to create repository on knot server.") return } + span.SetAttributes(attribute.Int("knot_response.status", resp.StatusCode)) switch resp.StatusCode { case http.StatusConflict: @@ -728,8 +764,9 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { } repo.AtUri = atresp.Uri - err = db.AddRepo(tx, repo) + err = db.AddRepo(ctx, tx, repo) if err != nil { + span.RecordError(err) log.Println(err) s.pages.Notice(w, "repo", "Failed to save repository information.") return @@ -739,6 +776,7 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { p, _ := securejoin.SecureJoin(user.Did, repoName) err = s.enforcer.AddRepo(user.Did, domain, p) if err != nil { + span.RecordError(err) log.Println(err) s.pages.Notice(w, "repo", "Failed to set up repository permissions.") return @@ -746,6 +784,7 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { err = tx.Commit() if err != nil { + span.RecordError(err) log.Println("failed to commit changes", err) http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -753,6 +792,7 @@ func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { err = s.enforcer.E.SavePolicy() if err != nil { + span.RecordError(err) log.Println("failed to update ACLs", err) http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go index b8c5987..04f2135 100644 --- a/telemetry/telemetry.go +++ b/telemetry/telemetry.go @@ -2,8 +2,9 @@ package telemetry import ( "context" + "fmt" - "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" @@ -60,7 +61,16 @@ func (t *Telemetry) Tracer() oteltrace.Tracer { return t.tracer } -func (t *Telemetry) TraceStart(ctx context.Context, name string) (context.Context, oteltrace.Span) { - tracer := otel.Tracer(t.serviceName) - return tracer.Start(ctx, name) +func (t *Telemetry) TraceStart(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, oteltrace.Span) { + ctx, span := t.tracer.Start(ctx, name) + span.SetAttributes(attrs...) + return ctx, span +} + +func MapAttrs[T any](attrs map[string]T) []attribute.KeyValue { + var result []attribute.KeyValue + for k, v := range attrs { + result = append(result, attribute.Key(k).String(fmt.Sprintf("%v", v))) + } + return result } -- 2.54.0