diff --git a/Management/DiagnosticsConfig.cs b/Management/DiagnosticsConfig.cs new file mode 100644 index 0000000..d6678bf --- /dev/null +++ b/Management/DiagnosticsConfig.cs @@ -0,0 +1,7 @@ +using System.Diagnostics; + +public static class DiagnosticsConfig +{ + public const string SourceName = "canvas-management-source"; + public static ActivitySource Source = new ActivitySource(SourceName); +} diff --git a/Management/Features/Configuration/QuizEditorContext.cs b/Management/Features/Configuration/QuizEditorContext.cs index 832562b..1e966a4 100644 --- a/Management/Features/Configuration/QuizEditorContext.cs +++ b/Management/Features/Configuration/QuizEditorContext.cs @@ -36,6 +36,8 @@ public class QuizEditorContext( public void SaveQuiz(LocalQuiz newQuiz) { + using var activity = DiagnosticsConfig.Source.CreateActivity("quiz creation requested", System.Diagnostics.ActivityKind.Server); + if (planner.LocalCourse != null && _module != null && Quiz != null) { // use Quiz not newQuiz because it is the version that was last stored diff --git a/Management/Services/Canvas/CanvasQuizService.cs b/Management/Services/Canvas/CanvasQuizService.cs index 418315c..11018df 100644 --- a/Management/Services/Canvas/CanvasQuizService.cs +++ b/Management/Services/Canvas/CanvasQuizService.cs @@ -7,12 +7,14 @@ namespace Management.Services.Canvas; public class CanvasQuizService( IWebRequestor webRequestor, CanvasServiceUtils utils, - CanvasAssignmentService assignments + CanvasAssignmentService assignments, + ILogger logger ) { private readonly IWebRequestor webRequestor = webRequestor; private readonly CanvasServiceUtils utils = utils; private readonly CanvasAssignmentService assignments = assignments; + private readonly ILogger logger = logger; public async Task> GetAll(ulong courseId) { @@ -33,6 +35,9 @@ public class CanvasQuizService( ulong? canvasAssignmentGroupId ) { + using var activity = DiagnosticsConfig.Source.StartActivity("Creating all canvas quiz"); + activity?.SetCustomProperty("localQuiz", localQuiz); + activity?.SetTag("canvas syncronization", true); Console.WriteLine($"Creating Quiz {localQuiz.Name}"); var url = $"courses/{canvasCourseId}/quizzes"; @@ -59,6 +64,7 @@ public class CanvasQuizService( var (canvasQuiz, response) = await webRequestor.PostAsync(request); if (canvasQuiz == null) throw new Exception("Created canvas quiz was null"); + activity?.SetCustomProperty("canvasQuizId", canvasQuiz.Id); await CreateQuizQuestions(canvasCourseId, canvasQuiz.Id, localQuiz); return canvasQuiz.Id; @@ -70,15 +76,27 @@ public class CanvasQuizService( LocalQuiz localQuiz ) { - var tasks = localQuiz.Questions.Select(createQuestion(canvasCourseId, canvasQuizId)).ToArray(); - await Task.WhenAll(tasks); + using var activity = DiagnosticsConfig.Source.StartActivity("Creating all quiz questions"); + activity?.SetCustomProperty("canvasQuizId", canvasQuizId); + activity?.SetTag("canvas syncronization", true); + + + var tasks = localQuiz.Questions.Select( + async (q, i) => await createQuestionOnly(canvasCourseId, canvasQuizId, q, i) + ).ToArray(); + var questionAndPositions = await Task.WhenAll(tasks); + await hackFixQuestionOrdering(canvasCourseId, canvasQuizId, questionAndPositions); await hackFixRedundantAssignments(canvasCourseId); } private async Task hackFixRedundantAssignments(ulong canvasCourseId) { - var canvasAssignments = await assignments.GetAll(canvasCourseId); + using var activity = DiagnosticsConfig.Source.StartActivity("hack fixing redundant quiz assignments that are auto-created"); + activity?.SetTag("canvas syncronization", true); + + + var canvasAssignments = await assignments.GetAll(canvasCourseId); var assignmentsToDelete = canvasAssignments .Where( assignment => @@ -99,24 +117,44 @@ public class CanvasQuizService( await Task.WhenAll(tasks); } - private Func createQuestion( - ulong canvasCourseId, - ulong canvasQuizId - ) + private async Task hackFixQuestionOrdering(ulong canvasCourseId, ulong canvasQuizId, IEnumerable<(CanvasQuizQuestion question, int position)> questionAndPositions ) { - return async (question) => await createQuestionOnly(canvasCourseId, canvasQuizId, question); + using var activity = DiagnosticsConfig.Source.StartActivity("hack fixing question ordering with reorder"); + activity?.SetCustomProperty("canvasQuizId", canvasQuizId); + activity?.SetTag("canvas syncronization", true); + + var order = questionAndPositions.OrderBy(t => t.position).Select(tuple => { + return new { + type = "question", + id = tuple.question.Id.ToString(), + }; + }).ToArray(); + + var url = $"courses/{canvasCourseId}/quizzes/{canvasQuizId}/reorder"; + + var request = new RestRequest(url); + request.AddBody(new { order }); + var response = await webRequestor.PostAsync(request); + + if (!response.IsSuccessStatusCode) + throw new NullReferenceException("error re-ordering questions, reorder response is not successfull"); } - private async Task createQuestionOnly( + private async Task<(CanvasQuizQuestion question, int position)> createQuestionOnly( ulong canvasCourseId, ulong canvasQuizId, - LocalQuizQuestion q + LocalQuizQuestion q, + int position ) { + using var activity = DiagnosticsConfig.Source.StartActivity("creating quiz question"); + activity?.SetTag("canvas syncronization", true); + activity?.SetTag("localQuestion", q); + activity?.SetCustomProperty("localQuestion", q); + activity?.SetTag("success", false); + var url = $"courses/{canvasCourseId}/quizzes/{canvasQuizId}/questions"; - var answers = q.Answers - .Select(a => new { answer_html = a.HtmlText, answer_weight = a.Correct ? 100 : 0 }) - .ToArray(); + var answers = getAnswers(q); var body = new { question = new @@ -124,16 +162,36 @@ public class CanvasQuizService( question_text = q.HtmlText, question_type = q.QuestionType + "_question", points_possible = q.Points, - // position + position, answers } }; var request = new RestRequest(url); request.AddBody(body); + var (newQuestion, response) = await webRequestor.PostAsync(request); if (newQuestion == null) throw new NullReferenceException("error creating new question, created question is null"); - return newQuestion; + + activity?.SetCustomProperty("canvasQuizId", newQuestion.Id); + activity?.SetTag("success", true); + + return (newQuestion, position); + } + + private static object[] getAnswers(LocalQuizQuestion q) + { + if(q.QuestionType == QuestionType.MATCHING) + return q.Answers + .Select(a => new { + answer_match_left = a.Text, + answer_match_right = a.MatchedText + }) + .ToArray(); + + return q.Answers + .Select(a => new { answer_html = a.HtmlText, answer_weight = a.Correct ? 100 : 0 }) + .ToArray(); } } diff --git a/Management/Services/Files/FileStorageManager.cs b/Management/Services/Files/FileStorageManager.cs index 7d79e46..0bf2e78 100644 --- a/Management/Services/Files/FileStorageManager.cs +++ b/Management/Services/Files/FileStorageManager.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using LocalModels; using Management.Services; @@ -58,10 +57,3 @@ public class FileStorageManager } } - - -public static class DiagnosticsConfig -{ - public const string SourceName = "canvas-management-source"; - public static ActivitySource Source = new ActivitySource(SourceName); -} diff --git a/Management/Services/WebRequestor.cs b/Management/Services/WebRequestor.cs index d0fe298..325db51 100644 --- a/Management/Services/WebRequestor.cs +++ b/Management/Services/WebRequestor.cs @@ -1,3 +1,4 @@ +using System.Net; using Microsoft.Extensions.Configuration; using RestSharp; @@ -7,10 +8,12 @@ public class WebRequestor : IWebRequestor private string token; private RestClient client; private readonly IConfiguration _config; + private readonly ILogger logger; - public WebRequestor(IConfiguration config) + public WebRequestor(IConfiguration config, ILogger logger) { _config = config; + this.logger = logger; token = _config["CANVAS_TOKEN"] ?? throw new Exception("CANVAS_TOKEN not in environment"); @@ -33,22 +36,41 @@ public class WebRequestor : IWebRequestor public async Task PostAsync(RestRequest request) { + using var activity = DiagnosticsConfig.Source.StartActivity("sending post"); + activity?.AddTag("success", false); + request.AddHeader("Content-Type", "application/json"); - var response = await client.ExecutePostAsync(request); - if (!response.IsSuccessful) + + try { - Console.WriteLine(response.Content); - Console.WriteLine(response.ResponseUri); - Console.WriteLine("error with response"); - throw new Exception("error with response"); + var response = await client.ExecutePostAsync(request); + activity?.AddTag("url", response.ResponseUri); + + if (isRateLimited(response)) + logger.LogInformation("hit rate limit"); + + if (!response.IsSuccessful) + { + logger.LogError($"Error with response, response content: {response.Content}", response); + throw new Exception("error with response"); + } + activity?.AddTag("success", true); + return response; + } + catch (Exception e) + { + Console.WriteLine("inside post catch block"); + throw e; + } - return response; } + private static bool isRateLimited(RestResponse response) => + response.StatusCode == HttpStatusCode.Forbidden && response.Content?.Contains("403 Forbidden (Rate Limit Exceeded)") != null; + public async Task<(T?, RestResponse)> PostAsync(RestRequest request) { - request.AddHeader("Content-Type", "application/json"); - var response = await client.ExecutePostAsync(request); + var response = await PostAsync(request); return (deserialize(response), response); } @@ -75,11 +97,38 @@ public class WebRequestor : IWebRequestor public async Task DeleteAsync(RestRequest request) { - return await client.DeleteAsync(request); + // using var activity = DiagnosticsConfig.Source.StartActivity($"sending delete web request"); + // activity?.AddTag("success", false); + + try + { + + var response = await client.DeleteAsync(request); + if (isRateLimited(response)) + Console.WriteLine("after delete response in rate limited"); + // Console.WriteLine(response.Content); + // activity?.AddTag("url", response.ResponseUri); + // activity?.AddTag("success", true); + return response; + + } + catch (HttpRequestException e) + { + if (e.StatusCode == HttpStatusCode.Forbidden) // && response.Content == "403 Forbidden (Rate Limit Exceeded)" + logger.LogInformation("hit rate limit in delete"); + + Console.WriteLine(e.StatusCode); + // Console.WriteLine(); + throw e; + + } } private static T? deserialize(RestResponse response) { + // using var activity = DiagnosticsConfig.Source.StartActivity("deserializing response"); + // activity?.AddTag("url", response.ResponseUri); + if (!response.IsSuccessful) { Console.WriteLine(response.Content); diff --git a/canvas-development/docker-compose.yml b/canvas-development/docker-compose.yml new file mode 100644 index 0000000..5900f6c --- /dev/null +++ b/canvas-development/docker-compose.yml @@ -0,0 +1,46 @@ +services: + collector: + image: otel/opentelemetry-collector-contrib + volumes: + - ./otel-collector-config.yml:/etc/otelcol-contrib/config.yaml + ports: + - 1888:1888 # pprof extension + - 8888:8888 # Prometheus metrics exposed by the Collector + - 8889:8889 # Prometheus exporter metrics + - 13133:13133 # health_check extension + - 4317:4317 # OTLP gRPC receiver + - 4318:4318 # OTLP http receiver + - 55679:55679 # zpages extension + + zipkin: + image: ghcr.io/openzipkin/zipkin-slim + # Environment settings are defined here https://github.com/openzipkin/zipkin/blob/master/zipkin-server/README.md#environment-variables + environment: + - STORAGE_TYPE=mem + ports: + - 9411:9411 + # command: --logging.level.zipkin2=DEBUG + + grafana: + image: grafana/grafana + user: 1000:1000 + ports: + - 3000:3000 + restart: unless-stopped + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + # - GF_SECURITY_ADMIN_USER=admin + # - GF_SECURITY_ADMIN_PASSWORD=grafana + volumes: + - ./grafana-datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml + - canvas-management-grafana:/var/lib/grafana + - ./grafana-dashboard.yml:/etc/grafana/provisioning/dashboards/main.yaml + # - ./grafana-dashboard.json:/var/lib/grafana/dashboards/dash.json + + loki: + image: grafana/loki:2.9.0 + command: -config.file=/etc/loki/local-config.yaml + +volumes: + canvas-management-grafana: diff --git a/canvas-development/grafana-dashboard.json b/canvas-development/grafana-dashboard.json new file mode 100644 index 0000000..4b49ba7 --- /dev/null +++ b/canvas-development/grafana-dashboard.json @@ -0,0 +1,295 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 3, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": false, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "code", + "expr": "{exporter=\"OTLP\"} | json | line_format \"{{.job}} - {{.date}}{{.body}}\"\n", + "key": "Q-4bb1fa82-7097-421e-80c0-e77e5267bd1d-0", + "queryType": "range", + "refId": "A" + } + ], + "title": "New Panel", + "transformations": [], + "type": "logs" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "{__name__=\"http_server_active_requests\", http_request_method=\"GET\", instance=\"api:3000\", job=\"ApiMetrics\", url_scheme=\"http\"}" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": false, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "http_server_active_requests", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "PBFA97CFB590B2093" + }, + "editorMode": "code", + "expr": "rate(hit_get_total [1m])", + "instant": false, + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "get request rate", + "uid": "c9342e0c-c83c-4bf3-a801-b23a89deac88", + "version": 4, + "weekStart": "" +} \ No newline at end of file diff --git a/canvas-development/grafana-dashboard.yml b/canvas-development/grafana-dashboard.yml new file mode 100644 index 0000000..57d3797 --- /dev/null +++ b/canvas-development/grafana-dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: "Dashboard provider" + orgId: 1 + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true \ No newline at end of file diff --git a/canvas-development/grafana-datasource.yml b/canvas-development/grafana-datasource.yml new file mode 100644 index 0000000..eaf144b --- /dev/null +++ b/canvas-development/grafana-datasource.yml @@ -0,0 +1,20 @@ +apiVersion: 1 + +datasources: +- name: Prometheus + type: prometheus + url: http://prometheus:9090 + isDefault: true + access: proxy + editable: true +- name: Loki + type: loki + access: proxy + orgId: 1 + url: http://loki:3100 + basicAuth: false + version: 1 + editable: false +- name: Zipkin + type: zipkin + url: http://zipkin:9411 diff --git a/ops/otel-collector-config.yml b/canvas-development/otel-collector-config.yml similarity index 63% rename from ops/otel-collector-config.yml rename to canvas-development/otel-collector-config.yml index 54fcb2c..a39276b 100644 --- a/ops/otel-collector-config.yml +++ b/canvas-development/otel-collector-config.yml @@ -10,8 +10,12 @@ processors: exporters: # otlp: # endpoint: otelcol:4317 + # prometheus: + # endpoint: "0.0.0.0:1234" zipkin: endpoint: http://zipkin:9411/api/v2/spans + loki: + endpoint: "http://loki:3100/loki/api/v1/push" extensions: health_check: @@ -26,10 +30,10 @@ service: processors: [batch] exporters: [zipkin] # metrics: - # receivers: [] + # receivers: [otlp] # processors: [batch] - # exporters: [] - # logs: - # receivers: [] - # processors: [batch] - # exporters: [] + # exporters: [prometheus] + logs: + receivers: [otlp] + processors: [batch] + exporters: [loki] diff --git a/ops/local-dev/docker-compose.yml b/ops/local-dev/docker-compose.yml deleted file mode 100644 index 2a4c5f4..0000000 --- a/ops/local-dev/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -services: - collector: - image: otel/opentelemetry-collector-contrib - volumes: - - ../otel-collector-config.yml:/etc/otelcol-contrib/config.yaml - ports: - - 1888:1888 # pprof extension - - 8888:8888 # Prometheus metrics exposed by the Collector - - 8889:8889 # Prometheus exporter metrics - - 13133:13133 # health_check extension - - 4317:4317 # OTLP gRPC receiver - - 4318:4318 # OTLP http receiver - - 55679:55679 # zpages extension - - zipkin: - image: ghcr.io/openzipkin/zipkin-slim - container_name: zipkin - # Environment settings are defined here https://github.com/openzipkin/zipkin/blob/master/zipkin-server/README.md#environment-variables - environment: - - STORAGE_TYPE=mem - ports: - - 9411:9411 - # command: --logging.level.zipkin2=DEBUG diff --git a/requests/quiz.http b/requests/quiz.http index 0fc7b5e..45a175f 100644 --- a/requests/quiz.http +++ b/requests/quiz.http @@ -2,6 +2,30 @@ GET https://snow.instructure.com/api/v1/courses/958185/quizzes Authorization: Bearer {{$dotenv CANVAS_TOKEN}} +### +GET https://snow.instructure.com/api/v1/courses/926068/quizzes/3489332/questions +Authorization: Bearer {{$dotenv CANVAS_TOKEN}} + +### + + +POST https://snow.instructure.com/api/v1/courses/926068/quizzes/3489331/reorder +Authorization: Bearer {{$dotenv CANVAS_TOKEN}} +Content-Type: application/json + +{ + "order":[ + {"type":"question","id":"60973974"}, + {"type":"question","id":"60973977"}, + {"type":"question","id":"60973976"}, + {"type":"question","id":"60973975"}, + {"type":"question","id":"60973978"}, + {"type":"question","id":"60973979"}, + {"type":"question","id":"60973980"} + ] +} + + ### GET https://snow.instructure.com/api/v1/courses/958185/assignments Authorization: Bearer {{$dotenv CANVAS_TOKEN}} @@ -77,4 +101,4 @@ Content-Type: application/json } ] } -} \ No newline at end of file +}