collect submit source
This commit is contained in:
@@ -16,6 +16,8 @@ type taskRunCollector struct {
|
||||
mu sync.Mutex
|
||||
run models.TaskRunMetrics
|
||||
logger *slog.Logger
|
||||
|
||||
lastValidationFingerprint string
|
||||
}
|
||||
|
||||
var nonWord = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
@@ -23,10 +25,12 @@ var nonWord = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
func newTaskRunCollector(taskName string, interfaceType InterfaceType, logger *slog.Logger) *taskRunCollector {
|
||||
return &taskRunCollector{
|
||||
run: models.TaskRunMetrics{
|
||||
TaskName: taskName,
|
||||
InterfaceType: interfaceType,
|
||||
StartedAt: time.Now(),
|
||||
Logs: make([]models.TaskLogEntry, 0, 8),
|
||||
MetricsVersion: models.CurrentMetricsVersion,
|
||||
TaskName: taskName,
|
||||
InterfaceType: interfaceType,
|
||||
StartedAt: time.Now(),
|
||||
ValidationSource: models.ValidationTriggerUnknown,
|
||||
Logs: make([]models.TaskLogEntry, 0, 8),
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
@@ -41,6 +45,16 @@ func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) {
|
||||
return
|
||||
}
|
||||
|
||||
if entry.Level == "validation" {
|
||||
if entry.Action == "validate_check" {
|
||||
return
|
||||
}
|
||||
|
||||
if entry.Action == "validate_attempt" && entry.Result == "passed" {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
"task", c.run.TaskName,
|
||||
"interface", c.run.InterfaceType,
|
||||
@@ -53,6 +67,8 @@ func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) {
|
||||
c.logger.Error(entry.Message, attrs...)
|
||||
case "warn":
|
||||
c.logger.Warn(entry.Message, attrs...)
|
||||
case "validation":
|
||||
c.logger.Warn(entry.Message, attrs...)
|
||||
default:
|
||||
c.logger.Info(entry.Message, attrs...)
|
||||
}
|
||||
@@ -81,6 +97,9 @@ func (c *taskRunCollector) log(level, message string) {
|
||||
|
||||
func (c *taskRunCollector) recordUserAction(raw string) {
|
||||
source, actionText, target, result := normalizeUserAction(raw)
|
||||
if shouldIgnoreUserAction(source, actionText, target) {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -103,7 +122,16 @@ func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, an
|
||||
c.run.QuestionnaireUserQuit = userQuit
|
||||
|
||||
if len(answers) > 0 {
|
||||
c.run.QuestionnaireAnswers = answers
|
||||
nonNilAnswers := make(map[string]any, len(answers))
|
||||
for k, v := range answers {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
nonNilAnswers[k] = v
|
||||
}
|
||||
if len(nonNilAnswers) > 0 {
|
||||
c.run.QuestionnaireAnswers = nonNilAnswers
|
||||
}
|
||||
}
|
||||
|
||||
result := "completed"
|
||||
@@ -150,53 +178,119 @@ func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, an
|
||||
}
|
||||
}
|
||||
|
||||
func (c *taskRunCollector) recordValidation(feedback ValidationFeedback) {
|
||||
func (c *taskRunCollector) recordValidation(feedback ValidationFeedback, source models.ValidationTriggerSource) {
|
||||
c.mu.Lock()
|
||||
|
||||
c.run.ValidationAttempts++
|
||||
attempt := c.run.ValidationAttempts
|
||||
|
||||
result := "failed"
|
||||
if feedback.Success {
|
||||
result = "passed"
|
||||
c.run.ValidationSuccesses++
|
||||
} else {
|
||||
c.run.ValidationFailures++
|
||||
c.run.ValidationRefreshes++
|
||||
if isAutoValidationSource(source) {
|
||||
c.run.ValidationAutoRefreshes++
|
||||
}
|
||||
if source == "" {
|
||||
source = models.ValidationTriggerUnknown
|
||||
}
|
||||
if shouldPromoteValidationSource(c.run.ValidationSource, source) {
|
||||
c.run.ValidationSource = source
|
||||
}
|
||||
fingerprint := validationFingerprint(feedback)
|
||||
isDuplicateAttempt := fingerprint == c.lastValidationFingerprint
|
||||
|
||||
c.appendLog(models.TaskLogEntry{
|
||||
Level: "validation",
|
||||
Message: fmt.Sprintf("validation attempt %d", attempt),
|
||||
Source: "system",
|
||||
Action: "validate_attempt",
|
||||
Result: result,
|
||||
Attempt: attempt,
|
||||
})
|
||||
if !isDuplicateAttempt {
|
||||
c.run.ValidationAttempts++
|
||||
if source == models.ValidationTriggerManualSubmit {
|
||||
c.run.ValidationManualAttempts++
|
||||
}
|
||||
attempt := c.run.ValidationAttempts
|
||||
|
||||
for _, check := range feedback.Checks {
|
||||
checkResult := "failed"
|
||||
if check.Valid {
|
||||
checkResult = "passed"
|
||||
c.run.ValidationChecksPassed++
|
||||
result := "failed"
|
||||
if feedback.Success {
|
||||
result = "passed"
|
||||
c.run.ValidationSuccesses++
|
||||
if c.run.AttemptsToFirstSuccess == 0 {
|
||||
c.run.AttemptsToFirstSuccess = attempt
|
||||
c.run.TimeToFirstSuccessMs = time.Since(c.run.StartedAt).Milliseconds()
|
||||
}
|
||||
c.run.FailureReasonCode = ""
|
||||
} else {
|
||||
c.run.ValidationChecksFailed++
|
||||
c.run.ValidationFailures++
|
||||
c.run.FailureReasonCode = inferFailureReasonCode(feedback)
|
||||
}
|
||||
|
||||
c.appendLog(models.TaskLogEntry{
|
||||
Level: "validation",
|
||||
Message: fmt.Sprintf("validation check: %s", check.Message),
|
||||
Message: fmt.Sprintf("validation attempt %d", attempt),
|
||||
Source: "system",
|
||||
Action: "validate_check",
|
||||
Target: check.Message,
|
||||
Result: checkResult,
|
||||
Action: "validate_attempt",
|
||||
Result: result,
|
||||
Attempt: attempt,
|
||||
})
|
||||
|
||||
for _, check := range feedback.Checks {
|
||||
checkResult := "failed"
|
||||
if check.Valid {
|
||||
checkResult = "passed"
|
||||
c.run.ValidationChecksPassed++
|
||||
} else {
|
||||
c.run.ValidationChecksFailed++
|
||||
}
|
||||
|
||||
c.appendLog(models.TaskLogEntry{
|
||||
Level: "validation",
|
||||
Message: fmt.Sprintf("validation check: %s", check.Message),
|
||||
Source: "system",
|
||||
Action: "validate_check",
|
||||
Target: check.Message,
|
||||
Result: checkResult,
|
||||
Attempt: attempt,
|
||||
})
|
||||
}
|
||||
|
||||
c.lastValidationFingerprint = fingerprint
|
||||
}
|
||||
|
||||
c.run.LastValidationMessage = feedback.Message
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func inferFailureReasonCode(feedback ValidationFeedback) string {
|
||||
for _, check := range feedback.Checks {
|
||||
if !check.Valid {
|
||||
return normalizeFailureReason(check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
code := normalizeFailureReason(feedback.Message)
|
||||
if code == "" {
|
||||
return "validation_failed"
|
||||
}
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
func validationFingerprint(feedback ValidationFeedback) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.Grow(64 + len(feedback.Checks)*32)
|
||||
if feedback.Success {
|
||||
b.WriteString("1")
|
||||
} else {
|
||||
b.WriteString("0")
|
||||
}
|
||||
b.WriteString("|")
|
||||
b.WriteString(feedback.Message)
|
||||
|
||||
for _, check := range feedback.Checks {
|
||||
b.WriteString("|")
|
||||
if check.Valid {
|
||||
b.WriteString("1")
|
||||
} else {
|
||||
b.WriteString("0")
|
||||
}
|
||||
b.WriteString(":")
|
||||
b.WriteString(check.Message)
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *taskRunCollector) setCompleted(completed bool) {
|
||||
c.mu.Lock()
|
||||
c.run.Completed = completed
|
||||
@@ -223,11 +317,118 @@ func (c *taskRunCollector) finalize() models.TaskRunMetrics {
|
||||
c.run.DurationMs =
|
||||
c.run.EndedAt.Sub(c.run.StartedAt).Milliseconds()
|
||||
|
||||
c.run.RunOutcome = inferRunOutcome(c.run)
|
||||
|
||||
final := c.run
|
||||
final.Logs = append([]models.TaskLogEntry(nil), c.run.Logs...)
|
||||
return final
|
||||
}
|
||||
|
||||
func shouldIgnoreUserAction(source, actionText, target string) bool {
|
||||
if source != "web" || actionText != "request" {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Fields(strings.TrimSpace(target))
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
path := parts[1]
|
||||
if path == "/favicon.ico" || path == "/status" || path == "/status/modal" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/.well-known/") {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(path, "/dependencies") || strings.HasSuffix(path, "/dependencies/options") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isAutoValidationSource(source models.ValidationTriggerSource) bool {
|
||||
switch source {
|
||||
case models.ValidationTriggerAutoPoll, models.ValidationTriggerInitCheck, models.ValidationTriggerStatusCheck:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validationSourcePriority(source models.ValidationTriggerSource) int {
|
||||
switch source {
|
||||
case models.ValidationTriggerManualSubmit:
|
||||
return 4
|
||||
case models.ValidationTriggerStatusCheck:
|
||||
return 3
|
||||
case models.ValidationTriggerInitCheck:
|
||||
return 2
|
||||
case models.ValidationTriggerAutoPoll:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func shouldPromoteValidationSource(current, incoming models.ValidationTriggerSource) bool {
|
||||
return validationSourcePriority(incoming) >= validationSourcePriority(current)
|
||||
}
|
||||
|
||||
func normalizeFailureReason(message string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(message))
|
||||
|
||||
switch {
|
||||
case lower == "":
|
||||
return "validation_failed"
|
||||
case strings.Contains(lower, "address already in use"):
|
||||
return "interface_port_in_use"
|
||||
case strings.Contains(lower, "no issues were created"):
|
||||
return "no_issues_created"
|
||||
case strings.Contains(lower, "assignee") && strings.Contains(lower, "expected"):
|
||||
return "assignee_mismatch"
|
||||
case strings.Contains(lower, "status") && strings.Contains(lower, "expected"):
|
||||
return "status_mismatch"
|
||||
case strings.Contains(lower, "priority") && strings.Contains(lower, "expected"):
|
||||
return "priority_mismatch"
|
||||
case strings.Contains(lower, "title") && strings.Contains(lower, "expected"):
|
||||
return "title_mismatch"
|
||||
case strings.Contains(lower, "description") && strings.Contains(lower, "expected"):
|
||||
return "description_mismatch"
|
||||
case strings.Contains(lower, "expected") && strings.Contains(lower, "got"):
|
||||
return "value_mismatch"
|
||||
default:
|
||||
code := normalizeAction(message)
|
||||
if code == "" || code == "unknown_action" {
|
||||
return "validation_failed"
|
||||
}
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
func inferRunOutcome(run models.TaskRunMetrics) models.RunOutcome {
|
||||
if run.Completed {
|
||||
return models.RunOutcomeCompleted
|
||||
}
|
||||
|
||||
if run.QuestionnaireUserQuit {
|
||||
return models.RunOutcomeUserQuit
|
||||
}
|
||||
|
||||
lowerErr := strings.ToLower(strings.TrimSpace(run.Error))
|
||||
if lowerErr != "" {
|
||||
if strings.Contains(lowerErr, "address already in use") || strings.Contains(lowerErr, "bind:") {
|
||||
return models.RunOutcomeInfraError
|
||||
}
|
||||
if strings.Contains(lowerErr, "user quit") {
|
||||
return models.RunOutcomeUserQuit
|
||||
}
|
||||
}
|
||||
|
||||
return models.RunOutcomeUserIncomplete
|
||||
}
|
||||
|
||||
func normalizeUserAction(raw string) (source, actionText, target, result string) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -70,7 +70,7 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter
|
||||
// Validation
|
||||
feedbackChan := make(chan ValidationFeedback, 10)
|
||||
quitChan := make(chan bool, 1)
|
||||
submitChan := make(chan struct{}, 1)
|
||||
submitChan := make(chan models.ValidationTrigger, 1)
|
||||
|
||||
if validated, ok := i.(ValidatedInterface); ok {
|
||||
validated.SetChannels(feedbackChan, quitChan)
|
||||
@@ -82,8 +82,8 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter
|
||||
}
|
||||
|
||||
engine := &ValidationEngine{task: t}
|
||||
doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback) {
|
||||
collector.recordValidation(feedback)
|
||||
doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback, source models.ValidationTriggerSource) {
|
||||
collector.recordValidation(feedback, source)
|
||||
|
||||
if feedback.Success {
|
||||
collector.setCompleted(true)
|
||||
|
||||
@@ -3,24 +3,30 @@ package task
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/LazyBachelor/LazyPM/internal/models"
|
||||
)
|
||||
|
||||
type ValidationEngine struct {
|
||||
task Tasker
|
||||
}
|
||||
|
||||
func (v *ValidationEngine) Start(ctx context.Context, submitChan <-chan struct{}, onFeedback func(ValidationFeedback)) (done <-chan struct{}, stop chan<- struct{}) {
|
||||
func (v *ValidationEngine) Start(ctx context.Context, submitChan <-chan models.ValidationTrigger, onFeedback func(ValidationFeedback, models.ValidationTriggerSource)) (done <-chan struct{}, stop chan<- struct{}) {
|
||||
doneChan := make(chan struct{}, 1)
|
||||
stopChan := make(chan struct{}, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-submitChan:
|
||||
case trigger := <-submitChan:
|
||||
feedback := v.task.Validate(ctx)
|
||||
source := trigger.Source
|
||||
if source == "" {
|
||||
source = models.ValidationTriggerUnknown
|
||||
}
|
||||
|
||||
if onFeedback != nil {
|
||||
onFeedback(feedback)
|
||||
onFeedback(feedback, source)
|
||||
}
|
||||
|
||||
if feedback.Success {
|
||||
|
||||
Reference in New Issue
Block a user