All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.kestra.plugin.notifications.sentry.SentryAlert Maven / Gradle / Ivy

package io.kestra.plugin.notifications.sentry;

import io.kestra.core.models.annotations.Example;
import io.kestra.core.models.annotations.Plugin;
import io.kestra.core.models.annotations.PluginProperty;
import io.kestra.core.models.property.Property;
import io.kestra.core.models.tasks.VoidOutput;
import io.kestra.core.runners.RunContext;
import io.kestra.plugin.notifications.AbstractHttpOptionsTask;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.http.client.netty.DefaultHttpClient;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
import lombok.experimental.SuperBuilder;

import java.net.URI;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;

import static java.nio.charset.StandardCharsets.UTF_8;

@SuperBuilder
@ToString
@EqualsAndHashCode
@Getter
@NoArgsConstructor
@Schema(
    title = "Send a Sentry alert when a specific flow or task fails",
    description = "Add this task to a list of `errors` tasks to implement custom flow-level failure notifications. \n\n The only required input is a DSN string value, which you can find when you go to your Sentry project settings and go to the section `Client Keys (DSN)`. You can find more detailed description of how to find your DSN in the [following Sentry documentation](https://docs.sentry.io/product/sentry-basics/concepts/dsn-explainer/#where-to-find-your-dsn). \n\n You can customize the alert `payload`, which is a JSON object, or you can skip it and use the default payload created by kestra. For more information about the payload, check the [Sentry Event Payloads documentation](https://develop.sentry.dev/sdk/event-payloads/). \n\n The `event_id` is an optional payload attribute that you can use to override the default event ID. If you don't specify it (recommended), kestra will generate a random UUID. You can use this attribute to group events together, but note that this must be a UUID type. For more information, check the [Sentry documentation](https://docs.sentry.io/product/issues/grouping-and-fingerprints/)."
)
@Plugin(
    examples = {
        @Example(
            title = "Send a Sentry alert on a failed flow execution",
            full = true,
            code = """
                id: unreliable_flow
                namespace: company.team

                tasks:
                  - id: fail
                    type: io.kestra.plugin.scripts.shell.Commands
                    runner: PROCESS
                    commands:
                      - exit 1

                errors:
                  - id: alert_on_failure
                    type: io.kestra.plugin.notifications.sentry.SentryAlert
                    dsn: "{{ secret('SENTRY_DSN') }}" # format: https://[email protected]/xxx
                    endpointType: ENVELOPE"""
        ),
        @Example(
            title = "Send a custom Sentry alert",
            full = true,
            code = """
                id: sentry_alert
                namespace: company.team

                tasks:
                  - id: send_sentry_message
                    type: io.kestra.plugin.notifications.sentry.SentryAlert
                    dsn: "{{ secret('SENTRY_DSN') }}"
                    endpointType: "ENVELOPE"
                    payload: |
                      {
                          "timestamp": "{{ execution.startDate }}",
                          "platform": "java",
                          "level": "error",
                          "transaction": "/execution/id/{{ execution.id }}",
                          "server_name": "localhost:8080",
                          "message": {
                            "message": "Execution {{ execution.id }} failed"
                          },
                          "extra": {
                            "Namespace": "{{ flow.namespace }}",
                            "Flow ID": "{{ flow.id }}",
                            "Execution ID": "{{ execution.id }}",
                            "Link": "http://localhost:8080/ui/executions/{{flow.namespace}}/{{flow.id}}/{{execution.id}}"
                          }
                      }"""
        ),
    }
)
public class SentryAlert extends AbstractHttpOptionsTask {
    public static final String SENTRY_VERSION = "7";
    public static final String SENTRY_CLIENT = "java";
    public static final String SENTRY_DATA_MODEL = "event";
    public static final String SENTRY_FILE_NAME = "application.log";
    public static final String SENTRY_CONTENT_TYPE = "application/json";
    public static final String SENTRY_DSN_REGEXP = "^(https?://[a-f0-9]+@o[0-9]+\\.ingest\\.sentry\\.io/[0-9]+)$";
    public static final int PAYLOAD_SIZE_THRESHOLD = 1024 * 1024;    // 1MB for events
    public static final int ENVELOP_SIZE_THRESHOLD = 100 * 1024 * 1024;  // 100MB decompressed

    private static final String DEFAULT_PAYLOAD = """
        {
          "timestamp": "{{ execution.startDate }}",
          "platform": "java",
          "level": "error",
          "message": {
            "message": "Execution {{ execution.id }} failed"
          },
          "extra": {
            "Namespace": "{{ flow.namespace }}",
            "Flow ID": "{{ flow.id }}",
            "Execution ID": "{{ execution.id }}"
          }
        }""";

    @Schema(
        title = "Sentry DSN"
    )
    @PluginProperty(dynamic = true)
    @NotBlank
    protected String dsn;

    @Schema(
        title = "Sentry endpoint type"
    )
    @PluginProperty(dynamic = true)
    @Builder.Default
    protected EndpointType endpointType = EndpointType.ENVELOPE;

    @Schema(
        title = "Sentry event payload"
    )
    protected Property payload;

    @Override
    public VoidOutput run(RunContext runContext) throws Exception {
        String dsn = runContext.render(this.dsn);

        String url = dsn;
        if (dsn.matches(SENTRY_DSN_REGEXP)) {
            /*
            To make passing the correct API endpoint URL easier,
            users only need to provide the Sentry DSN, and we parse the required attributes for the URL
            using the following formats:
            STORE_URL: https://{HOST}/api/{PROJECT_ID}/store/?sentry_version=7&sentry_client=java&sentry_key={PUBLIC_KEY}
            ENVELOPE_URL: https://{HOST}/api/{PROJECT_ID}/envelope/?sentry_version=7&sentry_client=java&sentry_key={PUBLIC_KEY}
            */
            url = switch (endpointType) {
                case ENVELOPE -> EndpointType.ENVELOPE.getEnvelopeUrl(dsn);
                case STORE -> EndpointType.STORE.getEnvelopeUrl(dsn);
            };
        }

        try (DefaultHttpClient client = new DefaultHttpClient(URI.create(url), super.httpClientConfigurationWithOptions(runContext))) {
            String payload = runContext.render(this.payload).as(String.class).isPresent() ?
                runContext.render(runContext.render(this.payload).as(String.class).get()) :
                runContext.render(DEFAULT_PAYLOAD.strip());

            // Constructing the envelope payload
            String envelope = constructEnvelope((String) runContext.getVariables().get("eventId"), payload);

            // Trying to send to /envelope endpoint
            try {
                runContext.logger().debug("Attempting to send the following Sentry event envelope: {}", envelope);
                client.toBlocking().retrieve(HttpRequest.POST(url, envelope));
            } catch (HttpClientResponseException exception) { // Backward Compatibility cases
                int errorCode = exception.getStatus().getCode();
                if ((errorCode == 401 || errorCode == 404) && endpointType.equals(EndpointType.ENVELOPE)) {
                    // If the /envelope endpoint is Not Found or Unauthorized ("missing authorization information"), request UI to configure endpointType: store to send the request to /store endpoint.
                    runContext.logger().error("Envelope endpoint not supported; Please try to configure the store endpoint instead: endpointType: store");
                    throw exception;
                }
            }
        }

        return null;
    }

    /**
     * Helper method to construct the Envelope formatted payload.
     */
    private String constructEnvelope(String eventId, String payload) {
        return switch (endpointType) {
            case ENVELOPE -> {
                // Build Envelope Payload
                String envelope = "%s%n%s%n%s%n".formatted(getEnvelopeHeaders(eventId, dsn), getItemHeaders(payload.length()), payload);

                // Check envelope and payload against threshold sizes
                checkEnvelopeAndPayloadThresholds(envelope, payload);

                yield envelope;
            }
            case STORE -> payload;
        };
    }

    /**
     * Helper method to build envelope headers
     */
    private static String getEnvelopeHeaders(String eventId, String dsn) {
        eventId = Objects.isNull(eventId) ? UUID.randomUUID().toString().toLowerCase().replace("-", "") : eventId;
        String sentAt = Instant.now().toString();
        return "{\"event_id\":\"%s\",\"dsn\":\"%s\",\"sdk\":{\"name\":\"%s\",\"version\":\"%s\"},\"sent_at\":\"%s\"}".formatted(eventId, dsn, SENTRY_CLIENT, SENTRY_VERSION, sentAt);
    }

    /**
     * Helper method to build item headers
     */
    private static String getItemHeaders(int payloadLength) {
        return "{\"type\":\"%s\",\"length\":%d,\"content_type\":\"%s\",\"filename\":\"%s\"}".formatted(SENTRY_DATA_MODEL, payloadLength, SENTRY_CONTENT_TYPE, SENTRY_FILE_NAME);
    }

    /**
     * Helper method to Check envelope and payload against threshold sizes.
     */
    private static void checkEnvelopeAndPayloadThresholds(String envelope, String payload) {
        // Calculate the size of the envelope
        int envelopeSize = envelope.getBytes(UTF_8).length;
        int payloadSize = payload.getBytes(UTF_8).length;

        // Enforce size limits based on Sentry's documentation
        if (envelopeSize > ENVELOP_SIZE_THRESHOLD) {
            throw new IllegalArgumentException("Envelope size exceeds 100MB limit for decompressed data");
        }

        if (payloadSize > PAYLOAD_SIZE_THRESHOLD) {
            throw new IllegalArgumentException("Event payload size exceeds 1MB limit");
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy