Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/components/AWS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
## Actions

<CardGrid>
<LinkCard title="CloudWatch • Put Metric Data" href="#cloud-watch-•-put-metric-data" description="Push custom metrics to AWS CloudWatch" />
<LinkCard title="CodeArtifact • Copy Package Versions" href="#code-artifact-•-copy-package-versions" description="Copy package versions from one repository to another in the same domain" />
<LinkCard title="CodeArtifact • Create Repository" href="#code-artifact-•-create-repository" description="Create an AWS CodeArtifact repository in a domain" />
<LinkCard title="CodeArtifact • Delete Package Versions" href="#code-artifact-•-delete-package-versions" description="Permanently delete one or more package versions from a repository" />
Expand Down Expand Up @@ -94,6 +95,38 @@ Each alarm event includes:
}
```

<a id="cloud-watch-•-put-metric-data"></a>

## CloudWatch • Put Metric Data

The Put Metric Data component publishes one or more custom metric data points to AWS CloudWatch.

### Use Cases

- **Application telemetry**: Publish request counts, latency, and error rates
- **Business KPIs**: Send domain metrics (orders, signups, revenue) to dashboards
- **Operational visibility**: Feed custom service health metrics into alarms and autoscaling

### Configuration

- **Region**: AWS region where the metric data will be published
- **Namespace**: CloudWatch namespace for your custom metrics (for example: `MyService/Production`)
- **Metric Data**: List of metric points to publish, each with:
- **Metric Name** and **Value** (required)
- Optional **Unit**, **Timestamp**, **Storage Resolution**, and **Dimensions**

### Example Output

```json
{
"requestId": "b6a5cd4f-2a52-4f43-8b68-61f70a5cbb43",
"region": "us-east-1",
"namespace": "MyService/Production",
"metricCount": 2,
"metricNames": ["RequestCount", "LatencyMs"]
}
```

<a id="code-artifact-•-on-package-version"></a>

## CodeArtifact • On Package Version
Expand Down
1 change: 1 addition & 0 deletions pkg/integrations/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ func (a *AWS) Components() []core.Component {
&ecr.GetImage{},
&ecr.GetImageScanFindings{},
&ecr.ScanImage{},
&cloudwatch.PutMetricData{},
&lambda.RunFunction{},
}
}
Expand Down
214 changes: 214 additions & 0 deletions pkg/integrations/aws/cloudwatch/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package cloudwatch

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/superplanehq/superplane/pkg/core"
"github.com/superplanehq/superplane/pkg/integrations/aws/common"
)

const (
cloudWatchServiceName = "monitoring"
cloudWatchAPIVersion = "2010-08-01"
cloudWatchContentType = "application/x-www-form-urlencoded; charset=utf-8"
)

type Client struct {
http core.HTTPContext
region string
credentials *aws.Credentials
signer *v4.Signer
}

type PutMetricDataResponse struct {
RequestID string `xml:"ResponseMetadata>RequestId"`
}

type MetricDatum struct {
MetricName string
Value float64
Unit string
Timestamp *time.Time
StorageResolution *int
Dimensions []Dimension
}

type Dimension struct {
Name string
Value string
}

func NewClient(httpCtx core.HTTPContext, credentials *aws.Credentials, region string) *Client {
return &Client{
http: httpCtx,
region: region,
credentials: credentials,
signer: v4.NewSigner(),
}
}

func (c *Client) PutMetricData(namespace string, metricData []MetricDatum) (*PutMetricDataResponse, error) {
namespace = strings.TrimSpace(namespace)
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}

if len(metricData) == 0 {
return nil, fmt.Errorf("at least one metric datum is required")
}

values := url.Values{}
values.Set("Action", "PutMetricData")
values.Set("Version", cloudWatchAPIVersion)
values.Set("Namespace", namespace)

for i, metric := range metricData {
metricIndex := i + 1
metricName := strings.TrimSpace(metric.MetricName)
if metricName == "" {
return nil, fmt.Errorf("metricData[%d].metricName is required", i)
}

values.Set(fmt.Sprintf("MetricData.member.%d.MetricName", metricIndex), metricName)
values.Set(fmt.Sprintf("MetricData.member.%d.Value", metricIndex), strconv.FormatFloat(metric.Value, 'f', -1, 64))

unit := strings.TrimSpace(metric.Unit)
if unit != "" {
values.Set(fmt.Sprintf("MetricData.member.%d.Unit", metricIndex), unit)
}

if metric.Timestamp != nil {
values.Set(
fmt.Sprintf("MetricData.member.%d.Timestamp", metricIndex),
metric.Timestamp.UTC().Format(time.RFC3339),
)
}

if metric.StorageResolution != nil {
values.Set(
fmt.Sprintf("MetricData.member.%d.StorageResolution", metricIndex),
strconv.Itoa(*metric.StorageResolution),
)
}

for j, dimension := range metric.Dimensions {
dimensionIndex := j + 1
name := strings.TrimSpace(dimension.Name)
value := strings.TrimSpace(dimension.Value)
if name == "" {
return nil, fmt.Errorf("metricData[%d].dimensions[%d].name is required", i, j)
}
if value == "" {
return nil, fmt.Errorf("metricData[%d].dimensions[%d].value is required", i, j)
}

values.Set(fmt.Sprintf("MetricData.member.%d.Dimensions.member.%d.Name", metricIndex, dimensionIndex), name)
values.Set(fmt.Sprintf("MetricData.member.%d.Dimensions.member.%d.Value", metricIndex, dimensionIndex), value)
}
}

response := PutMetricDataResponse{}
if err := c.postForm(values, &response); err != nil {
return nil, err
}

return &response, nil
}

func (c *Client) postForm(values url.Values, out any) error {
body := values.Encode()
endpoint := fmt.Sprintf("https://monitoring.%s.amazonaws.com/", c.region)
req, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(body))
if err != nil {
return fmt.Errorf("failed to build request: %w", err)
}

req.Header.Set("Content-Type", cloudWatchContentType)
req.Header.Set("Accept", "application/xml")

if err := c.signRequest(req, []byte(body)); err != nil {
return err
}

res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer res.Body.Close()

responseBody, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}

if res.StatusCode < 200 || res.StatusCode >= 300 {
if awsErr := parseError(responseBody); awsErr != nil {
return awsErr
}

return fmt.Errorf("CloudWatch API request failed with %d: %s", res.StatusCode, string(responseBody))
}

if out == nil {
return nil
}

if err := xml.Unmarshal(responseBody, out); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}

return nil
}

func (c *Client) signRequest(req *http.Request, payload []byte) error {
hash := sha256.Sum256(payload)
payloadHash := hex.EncodeToString(hash[:])
return c.signer.SignHTTP(context.Background(), *c.credentials, req, payloadHash, cloudWatchServiceName, c.region, time.Now())
}

func parseError(body []byte) *common.Error {
var payload struct {
Error struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
Errors struct {
Error struct {
Code string `xml:"Code"`
Message string `xml:"Message"`
} `xml:"Error"`
} `xml:"Errors"`
}

if err := xml.Unmarshal(body, &payload); err != nil {
return nil
}

code := strings.TrimSpace(payload.Error.Code)
message := strings.TrimSpace(payload.Error.Message)
if code == "" && message == "" {
code = strings.TrimSpace(payload.Errors.Error.Code)
message = strings.TrimSpace(payload.Errors.Error.Message)
}

if code == "" && message == "" {
return nil
}

return &common.Error{
Code: code,
Message: message,
}
}
14 changes: 14 additions & 0 deletions pkg/integrations/aws/cloudwatch/example.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,23 @@ import (
//go:embed example_data_on_alarm.json
var exampleDataOnAlarmBytes []byte

//go:embed example_output_put_metric_data.json
var exampleOutputPutMetricDataBytes []byte

var exampleDataOnAlarmOnce sync.Once
var exampleDataOnAlarm map[string]any

var exampleOutputPutMetricDataOnce sync.Once
var exampleOutputPutMetricData map[string]any

func (t *OnAlarm) ExampleData() map[string]any {
return utils.UnmarshalEmbeddedJSON(&exampleDataOnAlarmOnce, exampleDataOnAlarmBytes, &exampleDataOnAlarm)
}

func (c *PutMetricData) ExampleOutput() map[string]any {
return utils.UnmarshalEmbeddedJSON(
&exampleOutputPutMetricDataOnce,
exampleOutputPutMetricDataBytes,
&exampleOutputPutMetricData,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"requestId": "b6a5cd4f-2a52-4f43-8b68-61f70a5cbb43",
"region": "us-east-1",
"namespace": "MyService/Production",
"metricCount": 2,
"metricNames": ["RequestCount", "LatencyMs"]
}
Loading
Loading