package export import ( "context" "fmt" "sort" "github.com/xuri/excelize/v2" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" ) var ( exportTracer = otel.Tracer("otel-bi.export") exportMeter = otel.Meter("otel-bi.export") exportRowsTotal, _ = exportMeter.Int64Counter( "export.rows_total", metric.WithDescription("Total rows exported to XLSX"), ) exportSizeBytes, _ = exportMeter.Int64Histogram( "export.file_size_bytes", metric.WithDescription("XLSX file size in bytes"), metric.WithUnit("By"), ) ) type Column struct { Key string Label string } // ToXLSXBytes writes rows to a single-sheet Excel workbook using the given // column spec (controls header labels and order) and returns the raw bytes. func ToXLSXBytes(ctx context.Context, sheetName string, cols []Column, rows []map[string]any) ([]byte, error) { ctx, span := exportTracer.Start(ctx, "export.xlsx", trace.WithAttributes(attribute.String("sheet_name", sheetName)), ) defer span.End() f := excelize.NewFile() defer f.Close() sheet := f.GetSheetName(0) if err := f.SetSheetName(sheet, sheetName); err != nil { return nil, err } // Header row for col, c := range cols { cell, _ := excelize.CoordinatesToCellName(col+1, 1) if err := f.SetCellValue(sheetName, cell, c.Label); err != nil { return nil, err } } // Data rows for rowIdx, row := range rows { for colIdx, c := range cols { cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2) _ = f.SetCellValue(sheetName, cell, fmtCell(row[c.Key])) } } buf, err := f.WriteToBuffer() if err != nil { return nil, err } b := buf.Bytes() span.SetAttributes( attribute.Int("row_count", len(rows)), attribute.Int("file_size_bytes", len(b)), ) exportRowsTotal.Add(ctx, int64(len(rows)), metric.WithAttributes(attribute.String("sheet", sheetName))) exportSizeBytes.Record(ctx, int64(len(b)), metric.WithAttributes(attribute.String("sheet", sheetName))) return b, nil } // GenericXLSX converts a slice of maps to XLSX with alphabetically-sorted headers. // Use ToXLSXBytes when column order matters. func GenericXLSX(ctx context.Context, sheetName string, rows []map[string]any) ([]byte, error) { if len(rows) == 0 { return ToXLSXBytes(ctx, sheetName, nil, nil) } keys := make([]string, 0, len(rows[0])) for k := range rows[0] { keys = append(keys, k) } sort.Strings(keys) cols := make([]Column, len(keys)) for i, k := range keys { cols[i] = Column{Key: k, Label: k} } return ToXLSXBytes(ctx, sheetName, cols, rows) } func fmtCell(v any) string { if v == nil { return "" } return fmt.Sprintf("%v", v) }