
Blog

Storing OpenTelemetry Metrics, Traces, and Logs in GridDB Cloud with Kafka
OpenTelemetry (OTel) is an open-source, vendor-neutral observability framework for cloud-native software. Rather than tying an application to a single monitoring vendor, it defines a common

OpenTelemetry (OTel) is an open-source, vendor-neutral observability framework for cloud-native software. Rather than tying an application to a single monitoring vendor, it defines a common way to generate, collect, and export telemetry across three signal types: metrics, traces, and logs; commonly called the three pillars of observability. Metrics are numeric measurements sampled over time, such as CPU utilization or request counts. Traces describe the path of a single request as it moves through a system, broken into individual units of work called spans. Logs are timestamped event records emitted by the application. The value of the three pillars comes from being able to correlate across them: spotting a latency spike in a trace, then jumping to the exact log lines emitted during that request. Realizing that benefit requires the signals to live somewhere you can query them together, which is where GridDB Cloud comes in. All three signals are, at their core, streams of timestamped events, making them a natural fit for a time-series database. We have written extensively about pairing Kafka with GridDB, because a data-ingestion pipeline like Kafka fits well with what GridDB is built for. Previously we used the GridDB Kafka Connector to push time-series data to GridDB Cloud over the Web API. With the release of GridDB Cloud v3.2, we can now connect to a cloud instance natively, without the Web API, which opens up a broader set of tools. In this article, we will use that native connection to land all three OpenTelemetry signals into GridDB Cloud through Kafka. We will collect host metrics from a local machine, instrument a small application to emit traces and logs, route everything through Kafka, flatten the nested OTLP payloads with a Go bridge, and sink each signal into its own GridDB Cloud TIME_SERIES container using the GridDB Kafka Connector. We will then write queries that analyze the data, per-operation latency profiles, error rates, metric summaries, and a cross-signal lookup that connects a failing trace to the logs it produced. GridDB’s design favors one container per series, TIME_SERIES containers are optimized per series, and a query scoped to a single container is faster than filtering one large mixed container. That principle shapes the whole pipeline: rather than dumping raw OTLP into one place, we explode it into one container per metric, plus one container each for spans and logs. At a high level, this is what we will build: Set up the OpenTelemetry Collector to scrape host metrics and receive application traces and logs, pushing each signal to Kafka. Set up Kafka to receive and store the raw OTLP data. Run a Go “bridge” that reads the dense, nested OTLP JSON and explodes it into flat, one-row-per-event topics β one per metric, one for spans, one for logs. Use the GridDB Kafka Connector to sink those flattened topics into GridDB Cloud. host metrics ββ βββΊ OTel Collector ββΊ Kafka [otel-metrics] [otel-traces] [otel-logs] Instrumented app ββ β Go bridge (flatten nested OTLP) β Kafka [metric_system_cpu_utilization, …] [otel_spans] [otel_logs] β Kafka Connect (GridDB sink) β GridDB Cloud β one TIME_SERIES container per signal One naming detail to note up front: the raw input topics use hyphens (otel-metrics, otel-traces, otel-logs) and the bridge’s flattened output topics use underscores (metric_*, otel_spans, otel_logs). The output names double as GridDB container names, and the hyphen/underscore split lets the sink’s topic filter target only the flattened topics. OpenTelemetry Install OpenTelemetry from opentelemetry.io. In this case the Collector was installed on bare metal. Once installed, configure config.yaml to describe how the Collector should behave. The configuration below sets up all three pillars: the hostmetrics scraper produces metrics, while the otlp receiver accepts traces and logs from an instrumented application. Each pipeline exports to Kafka with OTLP-JSON encoding: receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 hostmetrics: collection_interval: 10s scrapers: cpu: metrics: system.cpu.utilization: enabled: true memory: metrics: system.memory.utilization: enabled: true load: disk: processors: resourcedetection: detectors: [system] system: hostname_sources: [os] batch: timeout: 5s send_batch_size: 100 exporters: kafka: brokers: – localhost:9092 metrics: topic: otel-metrics encoding: otlp_json traces: topic: otel-traces encoding: otlp_json logs: topic: otel-logs encoding: otlp_json debug: verbosity: basic service: pipelines: metrics: receivers: [otlp, hostmetrics] processors: [resourcedetection, batch] exporters: [kafka, debug] traces: receivers: [otlp] processors: [resourcedetection, batch] exporters: [kafka, debug] logs: receivers: [otlp] processors: [resourcedetection, batch] exporters: [kafka, debug] The hostmetrics scraper is the metrics source, configured at the bottom of the file in the metrics pipeline; it exports to the otel-metrics topic. The resourcedetection processor attaches the host’s name to every signal as a resource attribute. Start the Collector by pointing it at the config: $ otelcol-contrib –config ~/otel-griddb/config.yaml Instrumenting an Application for Traces and Logs Host metrics arrive on their own through the scraper, but in a real world setting, you’d want to be collecting traces and logs coming from the application you’re monitoring. To mimic this sort of real-world-use-case, we have set up a small Python worker that simulates processing jobs and instrument it with OpenTelemetry’s auto-instrumentation. Auto-instrumentation works well in this case because the application does not need an SDK wired in by hand; the opentelemetry-instrument launcher configures everything based on the environment variables including the providers, exporters, and the logging handler. A representative worker: import logging import random import time import uuid from opentelemetry import trace logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("job-worker") tracer = trace.get_tracer("job-worker") JOB_TYPES = ["transcode_video", "resize_image", "send_email", "build_report"] def process_job(job_type: str, job_id: str) -> None: with tracer.start_as_current_span(job_type) as span: span.set_attribute("job.id", job_id) span.set_attribute("job.type", job_type) duration = random.uniform(0.05, 1.5) log.info("job %s (%s) started", job_id, job_type) time.sleep(duration) if random.random() < 0.1: # occasionally fail, so the data has errors to query span.set_status(trace.Status(trace.StatusCode.ERROR, "job failed")) log.error("job %s (%s) failed after %.2fs", job_id, job_type, duration) return log.info("job %s (%s) completed in %.2fs", job_id, job_type, duration) def main() -> None: log.info("worker starting") while True: job_type = random.choice(JOB_TYPES) process_job(job_type, uuid.uuid4().hex[:8]) time.sleep(random.uniform(0.2, 0.8)) if __name__ == "__main__": main() Telemetry destinations are set through environment variables, which the launcher reads to decide where to send spans and logs: $ export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 $ export OTEL_EXPORTER_OTLP_PROTOCOL=grpc $ export OTEL_SERVICE_NAME=job-worker $ export OTEL_TRACES_EXPORTER=otlp $ export OTEL_LOGS_EXPORTER=otlp $ export OTEL_METRICS_EXPORTER=none Run the worker under the launcher: $ opentelemetry-instrument python worker.py As a tip, you can keep these exports in a file (for example otel-env.sh) and load them with source otel-env.sh. Pasting a multi-line block directly into a shell can silently corrupt the values if the newlines arrive as the literal characters \n β each variable then absorbs the leftover export keyword, producing names such as otlpnexport that fail at startup with Requested component ‘otlpnexport’ not found. Sourcing a file avoids the issue. The OTEL_SERVICE_NAME value is attached to every span and log as the service.name resource attribute, which the bridge promotes to a dedicated column. Kafka This setup uses the KRaft build of Kafka, which no longer requires a separate ZooKeeper process. Install Kafka and start it by pointing at the default config; installed via Homebrew, it starts like this: $ kafka-server-start $(brew –prefix)/etc/kafka/kraft/server.properties Kafka Plugins and the GridDB Sink Wiring Kafka to GridDB Cloud requires the GridDB connector and its supporting gridstore JARs. Gather them into a directory of your choosing β here, a griddb subdirectory under a kafka-plugins folder in the home directory: $ β ls ~/kafka-plugins/griddb $ griddb-kafka-connector-0.6.jar gridstore-conf-5.8.0.jar $ gridstore-5.8.0.jar gridstore-jdbc-5.8.0.jar $ gridstore-advanced-5.8.0.jar gridstore-jdbc-call-logging-5.8.0.jar $ gridstore-call-logging-5.8.0.jar These JARs are gathered from the GridDB Cloud v3.2 support page. You will also need to build the GridDB Kafka Connector from this fork, which adds support for the connection.route and database configuration properties required for GridDB Cloud’s native connection. > Author note: Link the v3.2 support page (the [TODO v3.2 BLOG] reference from the original draft). Kafka Connect Kafka Connect needs a connect-standalone.properties worker configuration. Because the bridge emits Kafka Connect envelopes that carry an explicit schema, the value converter must have schemas enabled: # connect-standalone.properties bootstrap.servers=localhost:9092 key.converter=org.apache.kafka.connect.json.JsonConverter value.converter=org.apache.kafka.connect.json.JsonConverter key.converter.schemas.enable=false value.converter.schemas.enable=true offset.storage.file.filename=/tmp/connect.offsets offset.flush.interval.ms=10000 # Directory above the griddb folder; Connect scans subdirectories for plugins plugin.path=/Users/israelimru/kafka-plugins rest.port=8083 The sink configuration tells Connect how to push data to GridDB Cloud. A single sink handles all three signal types: every flattened topic β metrics, spans, and logs β carries a datetime field and maps to a TIME_SERIES container, so one topic filter and one timestamp transform cover them all. The TimestampConverter is essential: it coerces the integer epoch-millis datetime into a real Timestamp, which is what makes GridDB create a TIME_SERIES container keyed on time rather than a collection with a plain integer column. name=griddb-otel-sink connector.class=com.github.griddb.kafka.connect.GriddbSinkConnector tasks.max=1 cluster.name=[yourClusterName] user=[yourUser] password=[yourPassword] multicast=false notification.provider.url=[yourNotificationProviderURL] connection.route=PUBLIC database=[yourDatabase] container.type=TIME_SERIES topics.regex=metric_.*|otel_.* # Coerce the int64 epoch-millis datetime into a Timestamp before mapping, # so datetime becomes the TIMESTAMP row key of each TIME_SERIES container. transforms=TimestampConverter transforms.TimestampConverter.type=org.apache.kafka.connect.transforms.TimestampConverter$Value transforms.TimestampConverter.field=datetime transforms.TimestampConverter.target.type=Timestamp The topics.regex=metric_.|otel_. pattern matches the underscore output topics (metric_system_cpu_utilization, otel_spans, otel_logs) while ignoring the hyphenated raw inputs. Start the Connect worker with both files: $ connect-standalone ~/otel-griddb/connect-standalone.properties ~/otel-griddb/griddb-otel-sink.properties The connect-standalone command runs the Kafka Connect worker β the link between Kafka topics and external systems. It loads the GridDB sink plugin and starts the job defined in the sink properties, which consumes from the matching topics and writes each record as a row in GridDB Cloud. The Go Bridge OTLP JSON is deeply nested. A metrics message wraps resourceMetrics β scopeMetrics β metrics β dataPoints; a traces message wraps resourceSpans β scopeSpans β spans; logs follow the same shape. GridDB stores flat rows, so the bridge reads each raw OTLP topic and explodes it into flat, one-row-per-event records, each wrapped in a Kafka Connect envelope carrying its schema. The main loop dispatches on the source topic: switch rec.Topic { case metricsTopic: // otel-metrics produced, err = explode(rec.Value) case tracesTopic: // otel-traces produced, err = explodeTraces(rec.Value) case logsTopic: // otel-logs produced, err = explodeLogs(rec.Value) } Metrics The metrics handler walks down to each data point and emits one flat row per point, routing it to a per-metric topic (metricToTopic turns system.cpu.utilization into metric_system_cpu_utilization): func explode(payload []byte) ([]*kgo.Record, error) { var o otlpMetrics if err := json.Unmarshal(payload, &o); err != nil { return nil, fmt.Errorf("unmarshal otlp: %w", err) } var out []*kgo.Record for _, rm := range o.ResourceMetrics { host := hostFromResource(rm.Resource) for _, sm := range rm.ScopeMetrics { for _, m := range sm.Metrics { var series *dataSeries switch { case m.Gauge != nil: series = m.Gauge case m.Sum != nil: series = m.Sum default: continue // histograms not supported } topic := metricToTopic(m.Name) for _, dp := range series.DataPoints { val, ok := dpValue(dp) if !ok { continue } nanos, err := strconv.ParseInt(dp.TimeUnixNano, 10, 64) if err != nil { continue } envelope := connectEnvelope{ Schema: flatSchema, Payload: flatRow{ Datetime: nanos / 1_000_000, Value: val, Host: host, Unit: m.Unit, Attrs: attrsToString(dp.Attributes), }, } b, _ := json.Marshal(envelope) out = append(out, &kgo.Record{Topic: topic, Value: b}) } } } } return out, nil } Traces The traces handler pulls service.name and host.name from the resource, iterates down to individual spans, converts nanosecond timestamps to milliseconds, and computes each span’s duration from its start and end times: func explodeTraces(payload []byte) ([]*kgo.Record, error) { var o otlpTraces if err := json.Unmarshal(payload, &o); err != nil { return nil, fmt.Errorf("unmarshal otlp traces: %w", err) } var out []*kgo.Record for _, rs := range o.ResourceSpans { host := resourceAttr(rs.Resource, "host.name") service := resourceAttr(rs.Resource, "service.name") for _, ss := range rs.ScopeSpans { for _, s := range ss.Spans { startNs, err1 := strconv.ParseInt(s.StartTimeUnixNano, 10, 64) endNs, err2 := strconv.ParseInt(s.EndTimeUnixNano, 10, 64) if err1 != nil || err2 != nil { continue } envelope := spanEnvelope{ Schema: spanSchema, Payload: spanRow{ Datetime: startNs / 1_000_000, DurationMs: (endNs – startNs) / 1_000_000, TraceID: s.TraceID, SpanID: s.SpanID, ParentSpanID: s.ParentSpanID, Name: s.Name, Kind: s.Kind, StatusCode: s.Status.Code, ServiceName: service, Host: host, Attrs: attrsToString(s.Attributes), }, } b, _ := json.Marshal(envelope) out = append(out, &kgo.Record{Topic: spansContainer, Value: b}) } } } return out, nil } Its schema leaves datetime as int64; the sink’s TimestampConverter promotes it to a GridDB TIMESTAMP row key: var spanSchema = connectSchema{ Type: "struct", Name: "otel_span", Fields: []connectField{ {Type: "int64", Optional: false, Field: "datetime"}, {Type: "int64", Optional: false, Field: "duration_ms"}, {Type: "string", Optional: true, Field: "trace_id"}, {Type: "string", Optional: true, Field: "span_id"}, {Type: "string", Optional: true, Field: "parent_span_id"}, {Type: "string", Optional: true, Field: "name"}, {Type: "int32", Optional: true, Field: "kind"}, {Type: "int32", Optional: true, Field: "status_code"}, {Type: "string", Optional: true, Field: "service_name"}, {Type: "string", Optional: true, Field: "host"}, {Type: "string", Optional: true, Field: "attrs"}, }, } Logs The logs handler is structurally identical, walking resourceLogs β scopeLogs β logRecords and emitting one flat row per record to the otel_logs topic. Each row carries the record’s severity number and text, its body, the service.name/host.name from the resource, and β critically β the trace_id and span_id that tie the log back to the span that produced it. That correlation key is the reason for landing logs and traces in the same database, and we use it in the queries below. Build and run the bridge: $ go build -o bridge $ ./bridge It logs each input record it explodes: 2026/05/15 13:31:36 topic=otel-metrics offset=150 exploded into 58 records 2026/05/15 13:31:37 topic=otel-traces offset=151 exploded into 12 records 2026/05/15 13:31:37 topic=otel-logs offset=149 exploded into 9 records Running Everything With every piece in place, start the components in order: Kafka (KRaft) OTel Collector The instrumented worker (opentelemetry-instrument python worker.py) The Go bridge Kafka Connect (connect-standalone) Once running, Kafka Connect reports writing rows to GridDB Cloud, with a container per metric plus the span and log containers: [griddb-otel-sink|task-0] Put 1 record to buffer of container metric_system_cpu_load_average_15m [griddb-otel-sink|task-0] Put 1 record to buffer of container metric_system_disk_io [griddb-otel-sink|task-0] Put 1 record to buffer of container otel_spans [griddb-otel-sink|task-0] Put 1 record to buffer of container otel_logs In the GridDB Cloud console, the containers appear as TIME_SERIES. The otel_spans container holds one row per span β datetime (the span start) as the TIMESTAMP row key, plus duration_ms, the ID fields (trace_id, span_id, parent_span_id), the operation name, kind, status_code, service_name, host, and a flattened attrs string. The otel_logs container holds one row per log record, keyed on datetime, with severity_number, severity_text, body, trace_id, span_id, service_name, host, and attrs. Each metric_* container holds datetime, value, host, unit, and attrs. Here’s a brief look at what our data looks like in the GridDB Cloud Portal Querying the Data Storing telemetry is only useful if you can ask questions of it. The following queries move from simple feeds to genuine analysis, one pillar at a time, and finish by crossing between them. Metrics For a single metric, a one-line aggregate summarizes its range over the whole collection period β useful for spotting how much disk io has run: SELECT MIN(value) AS min_util, AVG(value) AS avg_util, MAX(value) AS max_util FROM metric_system_disk_io; Example run using the GridDB Cloud CLI Tool: $ β ~ griddb-cloud-cli sql query -s "SELECT MIN(value) AS min_util, AVG(value) AS avg_util, MAX(value) AS max_util FROM metric_system_disk_io;" -r $ [{"stmt": "SELECT MIN(value) AS min_util, AVG(value) AS avg_util, MAX(value) AS max_util FROM metric_system_disk_io;" }] $ min_util,avg_util,max_util, $ [1.00627853312e+11 1.2719715549835805e+11 3.24776685568e+11] The latest readings give a live view of a single series: SELECT datetime, value, host FROM metric_system_disk_io ORDER BY datetime DESC LIMIT 20; Example run: $ β ~ griddb-cloud-cli sql query -s "SELECT datetime, value, host FROM metric_system_disk_io ORDER BY datetime DESC LIMIT 20;" -r $ [{"stmt": "SELECT datetime, value, host FROM metric_system_disk_io ORDER BY datetime DESC LIMIT 20;" }] $ datetime,value,host, $ [2026-05-27T20:54:31.978Z 1.65120098304e+11 Israels-Mac.local] $ [2026-05-27T20:54:21.979Z 1.65116936192e+11 Israels-Mac.local] $ [2026-05-27T20:54:11.979Z 1.6481210368e+11 Israels-Mac.local] $ [2026-05-27T20:54:01.980Z 1.64800229376e+11 Israels-Mac.local] $ [2026-05-27T20:53:51.979Z 1.64774305792e+11 Israels-Mac.local] $ [2026-05-27T20:53:41.979Z 1.64678656e+11 Israels-Mac.local] $ [2026-05-27T20:53:31.979Z 1.64668919808e+11 Israels-Mac.local] $ [2026-05-27T20:53:21.980Z 1.6465928192e+11 Israels-Mac.local] $ [2026-05-27T20:53:11.979Z 1.64600336384e+11 Israels-Mac.local] $ [2026-05-27T20:53:01.979Z 1.64591157248e+11 Israels-Mac.local] $ [2026-05-27T20:52:51.996Z 1.64583391232e+11 Israels-Mac.local] $ [2026-05-27T20:52:41.980Z 1.64520382464e+11 Israels-Mac.local] $ [2026-05-27T20:52:31.979Z 1.64511981568e+11 Israels-Mac.local] $ [2026-05-27T20:52:21.980Z 1.64502102016e+11 Israels-Mac.local] $ [2026-05-27T20:52:11.979Z 1.64488118272e+11 Israels-Mac.local] $ [2026-05-27T20:52:01.979Z 1.64480098304e+11 Israels-Mac.local] $ [2026-05-27T20:51:51.979Z 1.6446855168e+11 Israels-Mac.local] $ [2026-05-27T20:51:41.979Z 1.6444940288e+11 Israels-Mac.local] $ [2026-05-27T20:51:31.979Z 1.64428091392e+11 Israels-Mac.local] $ [2026-05-27T20:51:21.979Z 1.64419739648e+11 Israels-Mac.local] Traces A few conventions matter for the trace and log queries. A span’s status_code is 0 for UNSET, 1 for OK, and 2 for ERROR. A log record’s severity_number follows fixed ranges: 1β4 TRACE, 5β8 DEBUG, 9β12 INFO, 13β16 WARN, 17β20 ERROR, 21β24 FATAL. The first real question for any traced system is where the time is going. Because each span carries its own duration_ms, a single grouped query produces a full latency profile per operation β minimum, average, and maximum duration with the call count: SELECT name, COUNT(*) AS calls, MIN(duration_ms) AS min_ms, AVG(duration_ms) AS avg_ms, MAX(duration_ms) AS max_ms FROM otel_spans GROUP BY name ORDER BY avg_ms DESC; $ β ~ griddb-cloud-cli sql query -s "SELECT name, COUNT(*) AS calls, MIN(duration_ms) AS min_ms, AVG(duration_ms) AS avg_ms, MAX(duration_ms) AS max_ms FROM otel_spans GROUP BY name ORDER BY avg_ms DESC" -r $ [{"stmt": "SELECT name, COUNT(*) AS calls, MIN(duration_ms) AS min_ms, AVG(duration_ms) AS avg_ms, MAX(duration_ms) AS max_ms FROM otel_spans GROUP BY name ORDER BY avg_ms DESC" }] $ name,calls,min_ms,avg_ms,max_ms, $ [job.transcode_video 3535 11 1455.5188118811882 953212] $ [job.generate_report 3609 12 889.7420338043779 1.000412e+06] $ [job.sync_inventory 3569 24 285.75231157186886 2580] $ [job.resize_image 3587 7 120.47058823529412 1619] $ [job.send_email 3660 15 57.44672131147541 207] A high max_ms against a low avg_ms points to intermittent outliers. To inspect those outliers directly, sort by duration: SELECT ts, name, duration_ms, trace_id FROM otel_spans ORDER BY duration_ms DESC LIMIT 10; $ β ~ griddb-cloud-cli sql query -s "SELECT ts, name, duration_ms, trace_id FROM otel_spans ORDER BY duration_ms DESC LIMIT 10" -r $ [{"stmt": "SELECT ts, name, duration_ms, trace_id FROM otel_spans ORDER BY duration_ms DESC LIMIT 10" }] $ ts,name,duration_ms,trace_id, $ [2026-07-01T21:39:34.774Z job.generate_report 1.000412e+06 11bedd5adde787c507fe6700220f2719] $ [2026-07-01T21:18:32.090Z job.transcode_video 953212 4c945d48dab3de71cf43e2be37bcc845] $ [2026-07-01T21:16:27.534Z job.transcode_video 107162 b23966ac1330eaff79a17b0c0adeb9b4] $ [2026-07-01T19:16:53.336Z job.transcode_video 8010 501f49c5d7a8b5e8774c2e8f013f27bf] $ [2026-07-01T22:26:21.396Z job.transcode_video 8010 9064d16e9f05ef6e67d2b7f32bc4368a] $ [2026-07-01T21:04:59.306Z job.transcode_video 8007 1a96c129efbba1ab93fc5263242b9f52] $ [2026-07-01T20:22:30.204Z job.transcode_video 8007 ad9e78a3ab6f3ab1bc21c9454395f6c5] $ [2026-07-01T20:44:08.697Z job.transcode_video 8006 b7429f02f990a3e7decd314ec8a61e45] $ [2026-07-01T20:29:03.554Z job.transcode_video 8006 84f1aa7a770aa5fc29ca409e71b93b6a] $ [2026-07-01T20:03:24.204Z job.transcode_video 8006 57207e6dedc7c6e24e76cb061e8e7022] Reliability is the next question. Since status_code = 2 marks an errored span, error counts per operation come from a single filtered aggregate: SELECT name, COUNT(*) AS errors FROM otel_spans WHERE status_code = 2 GROUP BY name ORDER BY errors DESC; $ β ~ griddb-cloud-cli sql query -s "SELECT name, COUNT(*) AS errors FROM otel_spans WHERE status_code = 2 GROUP BY name ORDER BY errors DESC" -r $ [{"stmt": "SELECT name, COUNT(*) AS errors FROM otel_spans WHERE status_code = 2 GROUP BY name ORDER BY errors DESC" }] $ name,errors, $ [job.generate_report 344] $ [job.transcode_video 208] $ [job.sync_inventory 142] $ [job.resize_image 69] $ [job.send_email 34] Logs A severity breakdown gives a quick health summary: SELECT severity_text, COUNT(*) AS count FROM otel_logs GROUP BY severity_text ORDER BY count DESC; $ β ~ griddb-cloud-cli sql query -s "SELECT severity_text, COUNT(*) AS count FROM otel_logs GROUP BY severity_text ORDER BY count DESC" -r $ [{"stmt": "SELECT severity_text, COUNT(*) AS count FROM otel_logs GROUP BY severity_text ORDER BY count DESC" }] $ severity_text,count, $ [INFO 17174] $ [ERROR 797] Filtering on the severity number isolates everything at ERROR level or above: SELECT datetime, severity_text, service_name, body FROM otel_logs WHERE severity_number >= 17 ORDER BY datetime DESC LIMIT 20; $ β ~ griddb-cloud-cli sql query -s "SELECT ts, severity_text, service_name, body FROM otel_logs WHERE severity_number >= 17 ORDER BY ts DESC LIMIT 20" -r $ [{"stmt": "SELECT ts, severity_text, service_name, body FROM otel_logs WHERE severity_number >= 17 ORDER BY ts DESC LIMIT 20" }] $ ts,severity_text,service_name,body, $ [2026-07-01T22:48:29.403Z ERROR job-worker job d9dd3342 (generate_report) failed after 0.47s: rate limited] $ [2026-07-01T22:48:18.950Z ERROR job-worker job 070712fb (sync_inventory) failed after 0.06s: invalid payload] $ [2026-07-01T22:48:13.213Z ERROR job-worker job d56f92f3 (generate_report) failed after 0.39s: downstream timeout] $ [2026-07-01T22:48:11.816Z ERROR job-worker job 05ef9096 (generate_report) failed after 0.25s: invalid payload] $ [2026-07-01T22:48:00.613Z ERROR job-worker job 4441a1ec (transcode_video) failed after 3.86s: downstream timeout] $ [2026-07-01T22:47:55.657Z ERROR job-worker job 48491076 (generate_report) failed after 0.64s: connection refused] $ [2026-07-01T22:47:28.637Z ERROR job-worker job 51316b2b (transcode_video) failed after 0.17s: rate limited] $ [2026-07-01T22:47:26.343Z ERROR job-worker job b554b041 (transcode_video) failed after 0.53s: connection refused] $ [2026-07-01T22:47:24.968Z ERROR job-worker job 3e71df8a (generate_report) failed after 0.67s: downstream timeout] $ [2026-07-01T22:47:14.096Z ERROR job-worker job 31e1be77 (transcode_video) failed after 0.05s: invalid payload] $ [2026-07-01T22:47:12.386Z ERROR job-worker job f638a350 (transcode_video) failed after 0.75s: connection refused] $ [2026-07-01T22:47:01.852Z ERROR job-worker job 3fe1ac79 (generate_report) failed after 0.45s: invalid payload] $ [2026-07-01T22:46:36.300Z ERROR job-worker job 346819a2 (generate_report) failed after 1.38s: downstream timeout] $ [2026-07-01T22:46:28.549Z ERROR job-worker job 483cabb8 (resize_image) failed after 0.05s: connection refused] $ [2026-07-01T22:46:14.511Z ERROR job-worker job c438270a (transcode_video) failed after 0.09s: connection refused] $ [2026-07-01T22:45:49.406Z ERROR job-worker job 60e89b69 (generate_report) failed after 0.46s: connection refused] $ [2026-07-01T22:45:14.076Z ERROR job-worker job e3404a92 (send_email) failed after 0.09s: rate limited] $ [2026-07-01T22:45:11.384Z ERROR job-worker job e5eaabd8 (generate_report) failed after 1.36s: invalid payload] $ [2026-07-01T22:45:08.027Z ERROR job-worker job e207ee04 (generate_report) failed after 1.64s: invalid payload] $ [2026-07-01T22:44:55.155Z ERROR job-worker job 3af41a8b (generate_report) failed after 1.43s: invalid payload] Crossing Signals The most compelling result of collectingg traces and logs in the same database is correlation. Suppose the error query above flags an operation that fails often. We can take its most recent failing trace and pull every log record emitted during that exact request, joined on trace_id. First identify the trace: SELECT trace_id, name, duration_ms FROM otel_spans WHERE status_code = 2 ORDER BY datetime DESC LIMIT 1; $ β ~ griddb-cloud-cli sql query -s "SELECT trace_id, name, duration_ms FROM otel_spans WHERE status_code = 2 ORDER BY ts DESC LIMIT 1" -r $ [{"stmt": "SELECT trace_id, name, duration_ms FROM otel_spans WHERE status_code = 2 ORDER BY ts DESC LIMIT 1" }] $ trace_id,name,duration_ms, $ [693c5d8f3479288991b91dafd43d20f1 job.generate_report 473] Then retrieve its logs in order: SELECT datetime, severity_text, body, span_id FROM otel_logs WHERE trace_id = '<trace_id from the previous result>' ORDER BY datetime; $ β ~ griddb-cloud-cli sql query -s "SELECT ts, severity_text, body, span_id FROM otel_logs WHERE trace_id = '693c5d8f3479288991b91dafd43d20f1' ORDER BY ts" -r $ [{"stmt": "SELECT ts, severity_text, body, span_id FROM otel_logs WHERE trace_id = '693c5d8f3479288991b91dafd43d20f1' ORDER BY ts" }] $ ts,severity_text,body,span_id, $ [2026-07-01T22:48:29.403Z ERROR job d9dd3342 (generate_report) failed after 0.47s: rate limited 7492c1f55f487fe2] In two short queries we go from “this operation is failing” to the precise log lines explaining why an investigation that typically spans two separate systems, performed here against one database. Conclusion With this pipeline in place, all three OpenTelemetry signals land in GridDB Cloud through a single path: the Collector handles ingestion and fan-out to Kafka, the Go bridge flattens nested OTLP into one clean row per event, and the GridDB Kafka Connector β using GridDB Cloud v3.2’s native connection β sinks each signal into a purpose-built TIME_SERIES container. Because the data is queryable with standard aggregates and the trace identifier is shared between spans and logs, GridDB becomes a single backend for the correlated analysis that observability work depends on: metric summaries, latency profiling, error attribution, and trace-to-log

In this tutorial, we’ll build a fully functional job board web application from the ground up. Our application will allow users to browse available positions, search for jobs based on specific skills, and administrators can manage job listings. We’ll be working with three powerful technologies: Spring Boot to handle our backend component, Thymeleaf for creating dynamic web pages, and GridDB Cloud as our scalable database solution. As an exciting bonus, we’ll also integrate Spring AI with OpenAI’s language model to automatically generate relevant skill tags from job descriptions. This project is designed to give you hands-on experience with real-world web development concepts. We’ll start with the basics, setting up our development environment and cofiguring our database connection, then gradually build up to more advanced features like search functionality and AI integration. By the time we’re finished, you’ll have a complete understanding of how modern web applications work, from data storage and business logic to user interfaces and AI-powered features. Prerequisites & Project Setup First, let’s make sure we have everything installed and configured properly before we start building our job board application. Development Tools: Java 17 or later, Maven 3.5+, and your favorite IDE (IntelliJ IDEA or VS Code) A GridDB Cloud account. You can sign up for a GridDB Cloud Free instance at https://form.ict-toshiba.jp/download_form_griddb_cloud_freeplan_e An OpenAI API account for the AI-powered skill generation feature. You can find your Secret API key on the API key page. After completing the prerequisites, we’ll create a new Spring Boot application using Spring Initializr. Here’s how we’ll set it up: Navigate to start.spring.io Configure your project: Project: Maven Language: Java Spring Boot: 3.5.x (latest stable version) Group: com.example Artifact: springboot-jobboard Java Version: 17 or later Add the following dependencies: Spring Web – for creating our REST controllers and web layer Thymeleaf – for server-side template rendering Spring Security – for basic authentication (we’ll keep it simple) Click Generate to download a ZIP file with our project structure Once you’ve downloaded and extracted the project, import it into your IDE. Then make sure we have the main project structure as follows: $ ββββjava $ β ββββcom $ β ββββexample $ β ββββspringbootjobboard $ β ββββconfig $ β ββββcontroller $ β ββββdomain $ β ββββmodel $ β ββββrepos $ β ββββrest $ β ββββsecurity $ β ββββservice $ β ββββutil $ β ββββwebapi $ β ββββacquisition We’ll then add the additional dependencies we need for GridDB Cloud integration and AI-powered features. <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.1</version> </dependency> <dependency> <groupId>com.github.f4b6a3</groupId> <artifactId>tsid-creator</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-text</artifactId> <version>1.14.0</version> </dependency> > :bulb: Tip: If you prefer to skip the setup process, you can clone the completed project repository here. After adding all dependencies, next configure the application properties. GridDB Configuration griddbcloud.base-url=YOUR_GRIDDBCLOUD_BASE_URL griddbcloud.auth-token=YOUR_GRIDDBCLOUD_AUTH_TOKEN OpenAI API Key spring.ai.openai.api-key=${OPENAI_API_KEY} Then Export your Open AI API keys as environment variables: $ export OPENAI_API_KEY="your_api_key_here" Database Integration To access the GridDB Web API endpoint, we must provide an access token in the HTTP Authorization header. The access token is a Base64 encoded string of the username and password, separated by a colon. To access the configured values above, we need to bind the properties defined in the application.properties file to a POJO class using the @ConfigurationProperties annotation. // GridDbCloudClientProperties.java @Component @ConfigurationProperties(prefix = "griddbcloud") public class GridDbCloudClientProperties { private String baseUrl; private String authToken; //setter, getter } Next, we create GridDbCloudClient under webapi package, a centralized place to construct all HTTP requests to the GridDB Cloud Web API. // GridDbCloudClient.java public class GridDbCloudClient { private final RestClient restClient; public GridDbCloudClient(String baseUrl, String authToken) { this.restClient = RestClient.builder() .baseUrl(baseUrl) .defaultHeader("Authorization", "Basic " + authToken) .defaultHeader("Content-Type", "application/json") .defaultHeader("Accept", "application/json") .build(); } public void createContainer(GridDbContainerDefinition containerDefinition) { restClient .post() .uri("/containers") .body(containerDefinition) .retrieve() .toBodilessEntity(); } } The org.springframework.web.client.RestClient is built and configured only once during application startup, and the same instance is reused. Use baseUrl() to set the common base URL for all requests made to the GridDB Cloud Web API. Configure the Authorization header that should be included in every request by default using defaultHeader(). The Accept HTTP request header tells the server that our client wants to receive a JSON content in the response. The Content-Type header tells the server that JSON data is being sent in the request body. Next, add a helper method for adding rows to the specified container. // GridDbCloudClient.java public void registerRows(String containerName, Object body) { ResponseEntity<String> result = restClient .put() .uri("/containers/" + containerName + "/rows") .body(body) .retrieve() .toEntity(String.class); } The registerRows method: insert or update multiple rows of data in a specific GridDB container through the Web API. It takes the container’s name and the rows to be registered as parameters. .body(body) we provide the Java object that will be automatically converted to JSON by Spring’s message converter. Next, we need to create a method to execute an SQL statement that combines rows from one or multiple tables. For example, search job postings by skill, need to join the table JobPost with Skill. The GridDB Web API endpoint executes one or more SQL SELECT statements on a specific database: URL: /:cluster/dbs/:database/sql/dml/query HTTP Method: Post Example request body: [ {"stmt" : "select * from container1"}, {"stmt" : "select * from myTable"} ] Here is the helper method: public SQLSelectResponse[] select(List<GridDbCloudSQLStmt> sqlStmts) { try { ResponseEntity<SQLSelectResponse[]> responseEntity = restClient .post() .uri("/sql/dml/query") .body(sqlStmts) .retrieve() .toEntity(SQLSelectResponse[].class); return responseEntity.getBody(); } catch (Exception e) { throw new GridDbException("Failed to execute /sql/dml/query",HttpStatusCode.valueOf(500),e.getMessage(),e); } } Core Data Model In a job board platform, the schema would include tables like Company, JobPost, JobPostSkill, SkillTag, and Users. The schema would facilitate efficient storage and retrieval of job postings, company information, job skills tags, and user roles. User has 3 roles: RECRUITER, ADMIN, and APPLICANT. Job post types are: FULL_TIME(“Full Time”), PART_TIME(“Part Time”), CONTRACT(“Contract”), INTERNSHIP(“Internship”) Work models are: ONSITE, HYBRID, and REMOTE. Each job post can have multiple skills. This database design should support the process of creating and searching jobs in general. We will build our application based on this design. Let’s start with our primary data in a job board. Job Post The job_post table is the most important data model, representing a single job listing. We need to create a class to centralize the database operations from creating tables, querying rows, and creating or updating rows. Here is our container class: service/JobPostContainer.java @Component public class JobPostContainer { private final Logger log = LoggerFactory.getLogger(getClass()); private final GridDbCloudClient gridDbCloudClient; private static final String TBL_NAME = "JBJobPost"; public JobPostContainer(GridDbCloudClient gridDbCloudClient) { this.gridDbCloudClient = gridDbCloudClient; } public void createTable() { List<GridDbColumn> columns = List.of( new GridDbColumn("id", "STRING", Set.of("TREE")), new GridDbColumn("title", "STRING"), new GridDbColumn("description", "STRING"), new GridDbColumn("jobType", "STRING", Set.of("TREE")), new GridDbColumn("maximumMonthlySalary", "DOUBLE"), new GridDbColumn("datePosted", "TIMESTAMP"), new GridDbColumn("companyId", "STRING", Set.of("TREE")), new GridDbColumn("workModel", "STRING", Set.of("TREE")), new GridDbColumn("location", "STRING"), new GridDbColumn("applyUrl", "STRING")); GridDbContainerDefinition containerDefinition = GridDbContainerDefinition.build(TBL_NAME, columns); this.gridDbCloudClient.createContainer(containerDefinition); } } Here, the GridDbCloudClient is being injected into JobPostContainer via the constructor. By using constructor injection, we get some advantages: preventing circular dependencies at compile time and easier to unit test by simply passing mock or stub implementations of dependencies directly to the constructor during testing. public void saveRecords(List<JobPostRecord> jobPostRecords) { StringBuilder sb = new StringBuilder(); sb.append("["); for (int i = 0; i < jobPostRecords.size(); i++) { JobPostRecord record = jobPostRecords.get(i); sb.append("["); sb.append("\"").append(record.id()).append("\""); sb.append(", "); sb.append("\"").append(StringEscapeUtils.escapeJson(record.title())).append("\""); sb.append(", "); sb.append("\"").append(StringEscapeUtils.escapeJson(record.description())).append("\""); sb.append(", "); sb.append("\"").append(record.jobType().name()).append("\""); sb.append(", "); sb.append(record.maximumMonthlySalary()); sb.append(", "); sb.append("\"") .append(DateTimeUtil.formatToZoneDateTimeString(record.datePosted())) .append("\""); sb.append(", "); sb.append("\"").append(record.companyId()).append("\""); sb.append(", "); sb.append("\"").append(record.workModel().name()).append("\""); sb.append(", "); if (record.location() != null) { sb.append("\"") .append(StringEscapeUtils.escapeJson(record.location())) .append("\""); } else { sb.append("null"); } sb.append(", "); if (record.applyUrl() != null) { sb.append("\"").append(record.applyUrl()).append("\""); } else { sb.append("null"); } sb.append("]"); if (i < jobPostRecords.size() – 1) { sb.append(", "); } } sb.append("]"); String result = sb.toString(); this.gridDbCloudClient.registerRows(TBL_NAME, result); } saveRecords(List jobPostRecords): converts a list of JobPostRecord objects into a JSON-formatted string array and saves it to the GridDB instance using GridDbCloudClient. For datePosted, we should convert it into a string as UTC time format like YYYY-MM-DDThh:mm:ss.SSSZ. We escape the character in a String to prevent JSON parsing errors. public List<JobPostRecord> getAll() { AcquireRowsRequest requestBody = AcquireRowsRequest.builder().limit(50L).sort("id ASC").build(); AcquireRowsResponse response = this.gridDbCloudClient.acquireRows(TBL_NAME, requestBody); if (response == null || response.getRows() == null) { log.error("Failed to acquire rows from GridDB"); return List.of(); } List<JobPostRecord> jobPosts = convertResponseToRecord(response.getRows()); return jobPosts; } public List<JobPostRecord> searchBySkill(String skill) { String stmt = """ SELECT jp.* \ FROM JBJobPost jp \ JOIN JBCompany c ON jp.companyId = c.id \ JOIN JBJobPostSkill jps ON jp.id = jps.jobPostId \ JOIN JBSkillTag st ON jps.skillTagId = st.id \ WHERE LOWER(st.name) IN ('%s') \ GROUP BY jp.id, c.name """.formatted(skill.toLowerCase()); List<GridDbCloudSQLStmt> statementList = List.of(new GridDbCloudSQLStmt(stmt)); SQLSelectResponse[] response = this.gridDbCloudClient.select(statementList); if (response == null || response.length != statementList.size()) { // log.error("ERROR"); return List.of(); } List<List<Object>> results = response[0].getResults(); if (results.isEmpty()) { log.info("No result for searching skill: {}", skill); return List.of(); } List<JobPostRecord> records = convertResponseToRecord(results); return records; } getAll(): retrieves all job post records from a GridDB database, converts them into JobPostRecord objects, and returns them as a List. searchBySkill(String skill): find job posts that require a specific skill. We create a SQL query using Java’s text block. The SQL query selects all columns from the JobPost table, joins with the Company, JobPostSkill, and SkillTag tables, then filters by skill name. Then wraps the SQL statement in a GridDbCloudSQLStmt object and sends it to the GridDB Cloud using the select method of the client. Service Layer Next, we’ll add the service layer that sits between the Web Controller and the Data Access layer. @Service public class JobPostGridDbService { private final JobPostContainer jobPostContainer; public JobPostGridDbService(JobPostContainer jobPostContainer) { this.jobPostContainer = jobPostContainer; } public static String nextId() { return TsidCreator.getTsid().format("job_%s"); } public List<JobPostDTO> findAll(String searchSkill) { final List<JobPostRecord> jobPosts; if (searchSkill != null && !searchSkill.isBlank()) { jobPosts = jobPostContainer.searchBySkill(searchSkill); } else { jobPosts = jobPostContainer.getAll(); } return jobPosts.stream() .map(jobPost -> mapToDTO(jobPost, new JobPostDTO())) .collect(Collectors.toList()); } public String create(final JobPostDTO jobPostDTO) { String id = (jobPostDTO.getId() != null) ? jobPostDTO.getId() : nextId(); JobPostRecord newJobPost = new JobPostRecord( id, jobPostDTO.getTitle(), jobPostDTO.getDescription(), jobPostDTO.getJobType(), jobPostDTO.getMaximumMonthlySalary(), jobPostDTO.getDatePosted(), jobPostDTO.getCompanyId(), jobPostDTO.getWorkModel(), jobPostDTO.getLocation(), jobPostDTO.getApplyUrl()); jobPostContainer.saveRecords(List.of(newJobPost)); return id; } } This service class hides the database operation After getting the query result, transform it into a DTO class. @Service public class JobPostSkillGridDbService { private final JobPostSkillContainer jobPostSkillContainer; public JobPostSkillGridDbService(JobPostSkillContainer jobPostSkillContainer) { this.jobPostSkillContainer = jobPostSkillContainer; } public void replaceSkillsForJobPost(String jobPostId, List<String> skillTagIds) { deleteByJobPostId(jobPostId); if (!skillTagIds.isEmpty()) { createSkillsForJobPost(jobPostId, skillTagIds); } } } replaceSkillsForJobPost: update the list of skills for a job. It first removes all skill associations for the current job, then creates new associations between the job post and each skill tag ID in the list. Web Controller Next, let’s add the web controller class. This layer is the entry point of our web application. It receives requests, coordinates with the service layer to fulfill the data requested, and ensures users get the responses they expect. We will try to keep the controller thin and focus on its core responsibilities. controller/JobPostController.java @Controller @RequestMapping("/jobs") public class JobPostController { private final Logger log = LoggerFactory.getLogger(getClass()); private final JobPostGridDbService jobPostService; private final CompanyGridDbService companyService; private final JobPostSkillGridDbService jobPostSkillService; private final SkillTagGridDbService skillTagService; private final ChatModel chatModel; private final TableSeeder tableSeeder; private final Map<String, String> jobTypeValues = Arrays.stream(JobPostType.values()) .collect( java.util.stream.Collectors.toMap( JobPostType::name, JobPostType::getLabel)); public JobPostController( final JobPostGridDbService jobPostService, final CompanyGridDbService companyService, final JobPostSkillGridDbService jobPostSkillService, final SkillTagGridDbService skillTagService, ChatModel chatModel, TableSeeder tableSeeder) { this.jobPostService = jobPostService; this.companyService = companyService; this.jobPostSkillService = jobPostSkillService; this.skillTagService = skillTagService; this.chatModel = chatModel; this.tableSeeder = tableSeeder; } @ModelAttribute public void prepareContext(final Model model) { Map<String, String> companies = companyService.findAll().stream() .collect( java.util.stream.Collectors.toMap( com -> com.getId(), com -> com.getName())); model.addAttribute("jobTypeValues", jobTypeValues); model.addAttribute("workModelValues", WorkModel.values()); model.addAttribute("companyIdValues", companies); } @GetMapping public String list( @RequestParam(name = "searchSkill", required = false) String searchSkill, final Model model) { List<JobPostDTO> jobs = jobPostService.findAll(searchSkill); List<JobListingResponse> jobPosts = jobs.stream() .map( jobPost -> { JobListingResponse response = buildJobPostResponse(jobPost.getId()); return response; }) .toList(); model.addAttribute("jobPosts", jobPosts); model.addAttribute("searchSkill", searchSkill); return "jobs/list"; } } All dependencies (service, component) are injected using constructor injection. Annotated with @ModelAttribute the prepareContext(final Model model) will be executed before every controller method. It populates common attributes (job types, work models, companies), making it available to all Thymeleaf templates, useful for building dropdowns. Frontend using Thymeleaf Thymeleaf provides a flexible approach to render dynamic web pages in Spring Boot applications. We use fragments to create reusable template components and structure the templates in logical directories. $ src/ $ βββ main/ $ βββ resources/ $ βββ templates/ $ β βββ layout.html $ β βββ authentication/ $ β βββ company/ $ β βββ home/ $ β βββ jobs/ $ β βββ fragments/ $ β βββ forms.html $ βββ static/ $ βββ css/ Job listing page <!DOCTYPE HTML> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout}"> <head> <title>[[#{jobPost.list.headline}]]</title> <style type="text/css"> </style> </head> <body> <div layout:fragment="content"> <!– Page Header –> <div class="page-header row mb-4"> <div class="col-md-8"> <h1 class="fw-bold">Find Your Dream Job</h1> <p class="fs-3">Browse through our latest job openings </p> </div> <div sec:authorize="hasRole('ADMIN')" class="col-md-4 text-md-end action-buttons mt-3 mt-md-0"> <a sec:authorize="hasRole('ADMIN')" th:href="@{/jobs/add}" class="btn btn-create btn-lg text-white me-2"><i class="bi bi-plus-circle me-1"></i> [[#{jobPost.list.createNew}]]</a> </div> </div> <div class="row"> <!– Filters Sidebar –> <div class="col-lg-3"> <form th:action="@{/jobs}" method="get"> <div class="filter-card card p-3 mb-4"> <div class="input-group"> <span class="input-group-text bg-transparent border-0"> <i class="bi bi-search"></i> </span> <input type="text" name="searchSkill" th:value="${searchSkill}" class="form-control border-0 bg-transparent" placeholder="Skill…"> <button class="btn btn-primary">Search</button> </div> </div> </form> </div> <!– Job Listings –> <div class="col-lg-9"> <div class="row row-cols-1 row-cols-md-2 g-4"> <div th:each="jobPost : ${jobPosts}" class="col"> <div class="card job-card h-100"> <div class="card-body"> <div class="d-flex justify-content-between align-items-start mb-3"> <div class="company-logo"><img th:src="@{https://ui-avatars.com/api/?name={name}(name=${jobPost.company.name})}" alt="Company Logo" width="32" height="32" class="rounded-circle me-2"></div> </div> <h5 class="card-title"> <a th:text="${jobPost.title}" th:href="@{/jobs/view/{id}(id=${jobPost.id})}" class="job-title-link">Developer</a> </h5> <p class="card-text mb-2"> <a href="#" class="text-decoration-none"><span th:text="${jobPost.company.name}">jobType</span></a> </p> <div class="mb-3"> <span th:text="${jobPost.jobType}" class="job-tag job-type">jobType</span> <!– <span class="job-tag salary">$7,000 – $9,000</span> –> <span th:text="${jobPost.workModel}" class="job-tag work-mode">Hybrid</span> </div> <p class="card-text text-muted small"> <i th:text="${jobPost.location}" class="bi bi-geo-alt me-1"> </i> </p> <p th:text="${#strings.abbreviate(jobPost.description, 150)}" class="card-text"></p> <div class="d-flex justify-content-between align-items-center action-buttons"> <span class="text-muted small"></span> <a sec:authorize="!hasRole('ADMIN')" th:href="@{/jobs/view/{id}(id=${jobPost.id})}" class="btn btn-lg btn-primary">View Details</a> <a sec:authorize="hasRole('ADMIN')" th:href="@{/jobs/edit/{id}(id=${jobPost.id})}" class="btn btn-lg btn-primary"><i class="bi bi-pencil-square"></i> Edit</a> </div> </div> </div> </div> </div> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> </div> </body> </html> The Create New Job button is only available for ADMIN. We provide a search form to let users filter jobs by skill. We use th:each attribute to iterate over a list of jobs. Each job post is shown as a card. The job description was abbreviated to 150 characters. Edit button only for ADMIN. The title is a clickable link to the job details. For the styling we uses Bootstrap 5 and custom CSS. Here is what it looks like as an admin: Spring AI Integration Next, we introduce an intelligent feature that automates the process of adding relevant skills to a job post. We will leverage Spring AI, a powerful library that simplifies communication between our Spring Boot application and advanced AI models from providers like OpenAI. The process is straightforward: when an admin is creating or editing a job post, they can click a “Generate Skills” button. Behind the scenes, our application prepares relevant data before sending it to the OpenAI model and consumes the output. The most important part, we want to turn the AI-generated response into structured data like a Java record. private List<SkillTagDTO> generateSkills(JobPostDTO jobPostDTO, List<SkillTagDTO> skillTags) throws JsonProcessingException, JsonMappingException { ObjectMapper objectMapper = new ObjectMapper(); String skillCatalogJson = objectMapper.writeValueAsString(skillTags); BeanOutputConverter<SkillResponse> outputConverter = new BeanOutputConverter<>(new ParameterizedTypeReference<SkillResponse>() {}); String format = outputConverter.getFormat(); // @formatter:off String promptStr = """ You are an AI assistant that extracts required skills from a job description. TASK: – Only return skills present in the provided JSON skill catalog. – Matching is case-insensitive. – Do not invent or include skills not in the catalog. – Output strictly as a JSON array of objects. NOW PROCESS: <JOB_DESCRIPTION> {jobDescription} </JOB_DESCRIPTION> <SKILL_LIST> {skillCatalog} </SKILL_LIST> {format} """; // @formatter:on Prompt prompt = PromptTemplate.builder() .template(promptStr) .build() .create( Map.of( "jobDescription",jobPostDTO.getDescription(), "skillCatalog",skillCatalogJson, "format",format), OpenAiChatOptions.builder() .responseFormat(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT, null)) .build()); var generation = this.chatModel.call(prompt).getResult(); String outputText = generation.getOutput().getText(); SkillResponse skillResponse = outputConverter.convert(outputText); return skillResponse.skills(); } record SkillResponse(List<SkillTagDTO> skills) {} We use the low-level ChatModel API directly. Use Jackson’s ObjectMapper to convert the list of SkillTagDTO objects into JSON string. We want the AI Model to give us skills that only present in our provided skill catalog. BeanOutputConverter: to generate a JSON schema based on a given Java class, which is then used to transform the LLM output into our desired type SkillResponse record. We build the prompt using a template and inject the job description, skill catalog (as JSON), and the output format. We configure the OpenAI Chat API to respond with a JSON object through OpenAiChatOptions. Send the prompt to the OpenAI chat model using its call method. Gets the AI’s output text, use outputConverter to parse the JSON response into SkillResponse object. Return the list of extracted skills. Demo showing generate skills from job description using Spring AI and OpenAI. Running the Project: End-to-End Execution $ export OPENAI_API_KEY=your_key Build the project and run the application in development mode. $ mvn clean package $ mvn spring-boot:run Open localhost. Conclusion In this tutorial, we’ve successfully built a fully functional job board web application that demonstrate the power of modern Java development. We’ve gained hands-on experience integrating Spring Boot, Thymeleaf, and GridDB Cloud to create a complete full-stack solution from the ground up. One of the most exciting aspects of our implementation was integrating Spring AI’s support for OpenAI’s Structured Outputs. This feature transforms how web handle AI-generated content by ensuring predictable, well-formatted responses. While this application provides a solid foundation for any job board platform, there are numerous features we can consider adding: A complete user authentication and registration. Allowing recruiters to register and post jobs under their own account. Introduce pagination to display jobs. Tracing the request and response of the LLM call into the observability platform. Add cache in generating skills. If the job description hasn’t changed, then we should not call the OpenAI

Choosing a managed time series database for your IoT or DevOps workload usually comes down to one nervous question: am I paying for performance, or just paying? So we put three of the most popular options head-to-head: GridDB Cloud, InfluxDB Cloud, and MongoDB Atlas, using the standardized Time Series Benchmark Suite (TSBS). Same workload, same methodology, real ingestion and query numbers. Below are some of the more interesting findings. 1. At the pay-as-you-go tier, GridDB Cloud and InfluxDB Cloud cost exactly the same. Identical rate cards, so performance becomes the only tiebreaker. GridDB delivered 4β5Γ higher query throughput at 4β5Γ lower latency. 2. InfluxDB choked on large batches. A 100,000-row batch threw 429 Too Many Requests errors instantly. GridDB sailed through the same load at roughly 44,000 rows/sec. 3. MongoDB Atlas M40 costs $407/month more than GridDB’s fixed plan β and lost every benchmark. On threshold-scan queries at 64 workers, GridDB ran about 10Γ faster. 4. GridDB scaled. The others hit a wall. InfluxDB capped out near 11.5 queries/sec; MongoDB plateaued at 32 workers. GridDB kept climbing past 64. Putting it all together, we can confidently say that there’s a GridDB Cloud pricing model that beats the competition whether your workload is bursty and variable or heavy and continuous. The full methodology, ingestion and query tables, architecture diagrams, and the per-query breakdowns behind every number above can be found in the whitepaper. Take a look now! Download the full white paper (PDF)

With the release of GridDB Cloud v3.2, we now get the ability to connect to GridDB Cloud from your local machine using the native NoSQL clients (Java, Python, etc.) β without having to spin up a VNet peering, without configuring a VPN, and without needing to use the Web API. If you’ve followed along with our previous blogs covering the Azure Marketplace signup, the VNet peering setup, or the various Azure Connected Services integrations, you know that getting the native API working from outside the cloud used to be a bit tricky. You either had to host your code inside an Azure VNet peered to GridDB Cloud, set up a VPN to tunnel in, or resign yourself to the Web API. For local dev iteration, none of those options were super easy. With v3.2, that changes. You can now point your local Python or Java client directly at your cloud instance over the public internet, authenticate, and run queries. You can follow the official quick start guide but I will summarize and give some helpful tips that worked for me. GridDB Cloud v3.2 is available via the Azure Marketplace. If you haven’t signed up yet, grab either the Pay-As-You-Go plan or the Fixed Monthly plan. Our Azure Marketplace signup blog walks through the whole process. The Checklist Here’s the short version of what you need to do, in order. I’ll go deeper on each step below. First, we need to prepare our environment. Preparation Generate your notification provider URL from the Cloud dashboard before whitelisting Whitelist your local machine’s IP in the GridDB access area Download the EE-only library jars from the Cloud help page (they’re not on Maven) Extract gridstore-advanced.jar from the RPM if you’re not on Rocky Linux Install the Python client Add all the jars β including gridstore-advanced β to your CLASSPATH Append connectionRoute=PUBLIC to your connection details That last one is the whole reason this works. Without it, the client tries to connect over the private route and will timeout. 1. Generate the Notification Provider URL (Do This First!) Before you do anything else in the Cloud dashboard, head to the cluster settings and manually generate the notification provider URL. If you whitelist your IP first, it will fail with a strange warning. Save that URL somewhere β you’ll need it for your connection string. 2. Whitelist Your IP Now go to the access control area of the Cloud dashboard and add your local machine’s public IP to the whitelist. If you click ‘Add my IP’, it will automatically add your curren’t machine’s public IP Address. 3. Download the EE Library Files Head into cloud dashboard’s help/downloads/support section and grab the Enterprise Edition library bundle labeled as: GridDB Cloud Library and Plugin download . These jars are not available on Maven Central or anywhere else β they ship exclusively with the EE build of GridDB, which is what the Cloud runs on. You need these to be able to make SSL connections to the cloud. 4. Extract gridstore-advanced.jar from the RPM The EE download is distributed as an RPM. If you’re running Rocky Linux (or any RHEL-compatible distro), you can install it normally. But if you’re on Ubuntu, Debian, or pretty much anything else, you need to manually crack the RPM open to pull the jar out: $ rpm2cpio griddb-ee-java-lib-5.9.0-linux.x86_64.rpm | cpio -idmv This drops the contents into your current directory. The jar you want is gridstore-advanced.jar β it lives inside usr/share/java/ or similar after extraction. Without this jar on your classpath, your SSL handshake to the cloud will fail. 5. Install the Python Client Standard Python client install β nothing new here. Follow the official Python client getting started guide for the full walkthrough (install Java, clone the python_client repo, mvn install, then pip install .). 6. Add Everything to CLASSPATH Once you have all your jars in one place (gridstore.jar, gridstore-jdbc.jar, gridstore-arrow.jar, arrow-memory-netty.jar, and critically gridstore-advanced.jar), export your CLASSPATH: $ export CLASSPATH=/path/to/lib/gridstore.jar:/path/to/lib/gridstore-jdbc.jar:/path/to/lib/gridstore-arrow.jar:/path/to/lib/arrow-memory-netty.jar:/path/to/lib/gridstore-advanced.jar If gridstore-advanced.jar isn’t on this path, the connection will fail. 7. Add connectionRoute=PUBLIC This is the magic parameter that tells the client to use the new public route introduced in v3.2. In your Python code, your factory config should include it: self.gridstore = None try: self.gridstore = GridDB.factory.get_store( notification_provider=self.notification_provider, cluster_name=self.cluster_name, username=self.username, password=self.password, database=self.database, connection_route='PUBLIC' #NOTE, PUBLIC must be in ALL CAPS ) print(f"Successfully connected to {self.cluster_name}.") except Exception as e: print(f"Failed to connect to GridDB: {e}") Without this, the client will try to use the internal route and you’ll be stuck waiting. Python Example With all the pieces in place, here’s what a basic connect-and-query looks like from your local machine: $ (venv) israel@griddb:~/development/griddb-university/python$ export CLASSPATH=$CLASSPATH:./gridstore.jar:./gridstore-arrow.jar:./arrow-memory-netty.jar:./gridstore-advanced.jar $ (venv) israel@griddb:~/development/griddb-university/python$ export GRIDDB_NOTIFICATION_PROVIDER="URL" $ export GRIDDB_CLUSTER_NAME="gs_clustermfcloud87" $ export GRIDDB_USERNAME="admin" $ export GRIDDB_PASSWORD="password" $ export GRIDDB_DATABASE="nSt" $ (venv) israel@griddb:~/development/griddb-university/python$ python3 main.py $ JVM already started. $ Attempting to connect to GridDB… $ Successfully connected to gs_clustermfcloud8737. $ Successfully created TimeSeries: SamplePython_timeseries1 $ Successfully put row into SamplePython_timeseries1: [datetime.datetime(2025, 10, 1, 15, 0, tzinfo=datetime.timezone.utc), 10.21] $ — Reading from SamplePython_timeseries1 — $ [datetime.datetime(2025, 10, 1, 15, 0), 10.21] That’s it. No Azure Function wrapping, no container, no VPN client running in the background; just your script, talking directly to GridDB Cloud. The full sample python source code along with Java sample code is included with this article. Java from Your Local Machine As java is the native interface for GridDB, let’s also take a look at connecting via Java. The steps are largely the same, including the adding the new connectionRoute property and having the special library for making SSL requests to GridDB Cloud. Gotcha #1: URL-encode the Notification Provider Value When you pass the notification provider URL into Java’s GridStoreFactory, you need to URL-encode the value. If you don’t, Java’s property parser will see the &connectionRoute=PUBLIC portion as a separate parameter and silently drop it β and you’ll be left wondering why your connection is timing out even though everything looks right. The fix is to encode the full URL before passing it in: String notificationProvider = URLEncoder.encode( "https://<your-provider-url>?clusterName=<name>&connectionRoute=PUBLIC", StandardCharsets.UTF_8.toString() ); Gotcha #2: Manually Install gridstore-advanced.jar to Your Local Maven Repo Same jar as before, same reason β not on Maven Central. To use it with Maven, you have to install it to your local .m2 repository manually: $ mvn install:install-file \ $ -Dfile=/path/to/your/python/gridstore-advanced.jar \ $ -DgroupId=com.github.griddb \ $ -DartifactId=gridstore-advanced \ $ -Dversion=5.9.0 \ $ -Dpackaging=jar Then add it as a dependency in your pom.xml: <dependency> <groupId>com.github.griddb</groupId> <artifactId>gridstore-advanced</artifactId> <version>5.9.0</version> </dependency> Now Maven will resolve it like any other dependency when you build your project. Java Example $ (venv) israel@griddb:~/development/griddb-university/java$ java -jar target/java-samples-1.0-SNAPSHOT-jar-with-dependencies.jar $ jdbc:gs:///gs_clustermfcloud8737/nl7QftSt?notificationProvider=https%3A%2F%2Fdbaasshare&connectionRoute=PUBLIC $ CREATE TABLE IF NOT EXISTS exampleJdbc (id integer, value string); $ INSERT INTO exampleJdbc values (0, 'test0'),(1, 'test1'),(2, 'test2'),(3, 'test3'),(4, 'test4') $ SELECT * FROM exampleJdbc $ id value 0 test0 1 test1 2 test2 3 test3 4 test4 0 test0 1 test1 2 test2 3 test3 4 test4 $ Running SQL: SELECT ts, AVG(temp) as avg_temp FROM device WHERE ts BETWEEN TIMESTAMP('2020-07-12T00:01:20Z') AND TIMESTAMP('2020-07-12T00:14:00Z') GROUP BY RANGE (ts) EVERY(20, SECOND) $ java.sql.SQLException: [280005:SQL_DDL_TABLE_NOT_EXISTS] Parse SQL failed, reason = GET TABLE failed. (reason=GET TABLE failed. (reason=Specified table 'device' is not found)) on executing query (sql="SELECT ts, AVG(temp) as avg_temp FROM device WHERE ts BETWEEN TIMESTAMP('2020-07-12T00:01:20Z') AND TIMESTAMP('2020-07-12T00:14:00Z') GROUP BY RANGE (ts) EVERY(20, SECOND) ") (db='nl7QftSt') (user='S01K7vrCuF-israel') (clientId='c761d357-ec46-4e5c-8f80-405e69635810:4') (source={clientId=155, address=172.22.5.69:46422}) (connection=PUBLIC) (address=20.205.145.126:20001, partitionId=8289) $ Testing GridDB NoSQL $ Creating Container And again, the sample code will be shared here. In this case, we are connecting to GridDB Cloud via the NoSQL interface AND the SQL interface through JDBC. Both work here once the above steps are adhered to. C Client Please note, that if you would like to use the C Client, you will also need to follow the same procedure as the Java code but for the C Client. That is, you will need to include the ‘public route’ to your connection details and will need to extract the .rpm called griddb-ee-c-lib-5.8.0-linux.x86_64.rpm (assuming you are not using CentOS/Rocky Linux) and grab the library files libgridstore.so.0.0.0 and libgridstore_advanced.so.0.0.0 and the header (gridstore.h). Once you have those in place, add the public route to your connection details const GSPropertyEntry props[] = { { "notificationProvider", "https://<url-encoded-provider-url>" }, { "clusterName", "<your cluster name>" }, { "database", "public" }, { "user", "<user>" }, { "password", "<password>" }, { "sslMode", "PREFERRED" }, { "connectionRoute", "PUBLIC" } }; And you should be good to go for the C Client as well! Conclusion Being able to hit GridDB Cloud directly from your local dev machine is a genuinely big deal for iteration speed. If you haven’t signed up for GridDB Cloud v3.2 yet, it’s exclusively on the Azure Marketplace β you can grab the Pay-As-You-Go plan

This tutorial shows how to generate evolving ambient music driven by IoT sensor data. Weβll ingest sensor readings into GridDB database, map those readings to musical parameters using OpenAI, and call ElevenLabs Music to render an audio track. The UI is built with React + Vite, and the backend is Node.js. Introduction Ambient music thrives on context. Here, the environment literally composes the score. Heat can slow the tempo, humidity can soften the timbre, and human presence can thicken the arrangement. Weβll stitch together a small system: devices post telemetry (we will use the data directly), GridDB keeps the data, the AI model creates music parameters, and ElevenLabs will render audio that you can play instantly in the browser. System Architecture The system has several core components working together to turn IoT data into ambient sound: IoT Data Source Environmental sensors capture values such as temperature, humidity, sound levels, and occupancy. These readings are the raw input for the music generation process. Node.js Backend Node.js acts as the central orchestrator. It receives IoT sensor readings and coordinates interactions between the AI models, the music generator, and the database. OpenAI Model The IoT data is processed by an OpenAI model. The model transforms the data into a musical prompt. For example, βcalm ambient soundscape with airy textures and slow tempo.β This ensures the music reflects the current environment in a more human-like, descriptive way. ElevenLabs Music API The generated music prompt is sent to the ElevenLabs Music API. ElevenLabs then produces an audio track that matches the description. The result is ambient audio that adapts to real-world conditions. GridDB Database Both the music prompt and the audio metadata (such as file path or data URL) are stored in GridDB. GridDB also keeps the original IoT readings. React + Vite Frontend The frontend provides a web-based interface where users can trigger new music generation, view sensor snapshots, and play the most recent ambient tracks. Prerequisites Node.js This project is built using React + Vite, which requires Node.js version 16 or higher. You can download and install Node.js from https://nodejs.org/en. OpenAI Create the OpenAI API key here. You may need to create a project and enable a few models. In this project, we will use an AI model from OpenAI: gpt-5-mini to create an audio prompt. GridDB Sign Up for GridDB Cloud Free Plan If you would like to sign up for a GridDB Cloud Free instance, you can do so at the following link: https://form.ict-toshiba.jp/download_form_griddb_cloud_freeplan_e. After successfully signing up, you will receive a free instance along with the necessary details to access the GridDB Cloud Management GUI, including the GridDB Cloud Portal URL, Contract ID, Login, and Password. GridDB WebAPI URL Go to the GridDB Cloud Portal and copy the WebAPI URL from the Clusters section. It should look like this: GridDB Username and Password Go to the GridDB Users section of the GridDB Cloud portal and create or copy the username for GRIDDB_USERNAME. The password is set when the user is created for the first time. Use this as the GRIDDB_PASSWORD. For more details, to get started with GridDB Cloud, please follow this quick start guide. IP Whitelist When running this project, please ensure that the IP address where the project is running is whitelisted. Failure to do so will result in a 403 status code or forbidden access. You can use a website like What Is My IP Address to find your public IP address. To whitelist the IP, go to the GridDB Cloud Admin and navigate to the Network Access menu. ElevenLabs You need an ElevenLabs account and API key to use this project. You can sign up for an account at https://elevenlabs.io/signup. After signing up, go to the Developer section, and create and copy your API key. And make sure to enable the Music Generation access permission. How to Run 1. Clone the repository Clone the repository from https://github.com/junwatu/grid-sound-ambient to your local machine. $ git clone https://github.com/junwatu/grid-sound-ambient.git $ cd grid-sound-ambient $ cd apps 2. Install dependencies Install all project dependencies using npm. $ npm install 3. Set up environment variables Copy file .env.example to .env and fill in the values: # Copy this file to .env.local and add your actual API keys # Never commit .env.local to version control # ElevenLabs API Key for ElevenLabs Music ELEVENLABS_API_KEY= OPENAI_API_KEY= GRIDDB_WEBAPI_URL= GRIDDB_PASSWORD= GRIDDB_USERNAME= WEB_URL=http://localhost:3000 Please look at the section on Prerequisites before running the project. 4. Run the project Run the project using the following command: $ npm run start 5. Open the application Open the application in your browser at http://localhost:3000 or any address that WEB_URL is set to. You also need to allow the browser to access your microphone. Building The Ambient Music Generator IoT Data In this project, we will use pre-made IoT data. The data is an array of sensor snapshots. Each object is a single time-stamped reading for a building zone. This data mimics real data conditions from the IoT sensor. [ { "timestamp": "2025-08-20T09:15:00", "zone": "Meeting Room A", "temperature_c": 22.8, "humidity_pct": 47, "co2_ppm": 1020, "voc_index": 185, "occupancy": 7, "noise_dba": 49, "productivity_score": 65, "trend_10min.co2_ppm_delta": 120, "trend_10min.noise_dba_delta": 1, "trend_10min.productivity_delta": -5 }, … ] You can look at the data sample in the apps/data/iot_music_samples.json. User Interface The UI is a small React app (Vite + Tailwind) that drives the endβtoβend flow and plays generated audio. The workflow for the user is: Click the Load example button to load sensor data into the text input, or you can paste a single sensor snapshot JSON into the textarea from the apps/data/iot_music_samples.json file. Click βGenerate Musicβ to call. The app displays the generated prompt, a brief (expandable) description, and an HTML5 audio player. Optionally, you can open βView Historyβ to fetch recent records and replay saved tracks. These are the server routes used by the client-side UI: | Method & Route | Trigger in UI | Purpose Consumes | |—————————-|—————————————-|———————————————- | POST /api/iot/generate-music | Generate Music button | Full pipeline: brief β prompt β music β save | GET /api/music/history | View History modal | Load saved generations | GET /audio/ | Audio players in results/history | Stream ambient music from server The client data returned from the server is JSON. It contains all the data needed for the UI, from music prompt, music brief, to audio metadata such as audio path and filename. One thing to note here is that the OpenAI model is being used to generate music brief AND the music prompt. What’s the difference? Please, read the next section. Result UI Other than user input for IoT data snapshot, after successfully generating ambient music, the result user interface will render: Generated music prompt (+ Music bried details) Music player, it’s information, and the download link. History UI When the user clicks the View History button, the app changes state to display all generated music, associated metadata, simplified IoT data, music briefs, and prompts. Generate Music Prompt Music Brief This project generates a music brief before the final prompt to provide flexibility and a clear separation of concerns. The brief normalizes noisy IoT data into structured parameters, and the same brief can be reused with other (including nonβOpenAI) models without changing the mapping, making it robust for realβworld conditions. Here is an example of the music brief: { "mood": "soothing", "energy": 48, "tension": 30, "bpm": [ 50, 64 ], "duration_sec": 60, "loopable": true, "key_suggestion": "A minor", "instrument_focus": [ "warm pads", "soft piano", "breathy synth", "warm low strings", "subtle low percussion" ], "texture_notes": "Airy, sparse texture with warm low mids and a gentle high-frequency roll-off to avoid brightness.", "rationale": "CO2 >1000 ppm and rising calls for lower-energy, soothing airiness; occupancy is low and temp/humidity are ideal, so use sparse warm timbres and minimal rhythmic drive to reduce stress." } Music brief generation is handled by generateMusicBrief(sensorSnapshot). It takes a single IoT sensor snapshot and uses the OpenAI model gpt-5-mini to produce the brief. The full code can be found in the lib\openai.ts file. The important part of the code is the AI system prompt: const systemPrompt = ` You are an assistant that converts building sensor snapshots into a concise βmusic briefβ for an ambient soundtrack generator. Return ONLY compact JSON with these fields: { "mood": "calm|focused|energizing|soothing|alert|uplifting|neutral", "energy": 0-100, "tension": 0-100, "bpm": [low, high], "duration_sec": number, "loopable": true|false, "key_suggestion": "A minor|D minor|C major|… (optional)", "instrument_focus": ["pads","soft piano","light percussion", …], "texture_notes": "short sentence on space/density/brightness", "rationale": "1β2 sentences mapping readingsβchoice" } Decision rules: – High CO2 (>1000 ppm) or high VOC (>200) β lower energy (35β55), soothing/airiness to reduce stress; avoid bright highs. – High occupancy (>25) with good air (CO2 < 800) β moderate energy (55β70) and gentle momentum; keep distractions low (no sharp transients). – High noise (>60 dBA) β simpler textures, fewer rhythmic accents; tighten BPM range. – Productivity_score < 60 β light uplift (energy +10), but stay minimal. – Temperature 22β24Β°C & humidity 45β55% is ideal; if outside, reduce tension slightly and favor warm timbres. Prefer keys: minor for calming/focus, major for uplifting. Keep outputs steady and minimal; no reactivity to single-sample spikesβassume 10β15 min trend. `; This system prompts the behaviour of the model AI to create a music brief with a pre-defined data structure using decision rules. If you want to enhance this project, this is the crucial part where you can adjust the decision rules to your requirements. Music Prompt The music prompt is generated using the generateMusicPrompt(musicBrief) function. This function will call the OpenAI model gpt-5-mini to generate a music prompt based on the music brief input. const response = await openai.responses.create({ model: "gpt-5-mini", input: [ { role: "developer", content: [{ type: "input_text", text: systemPrompt }] }, { role: "user", content: [{ type: "input_text", text: JSON.stringify(brief, null, 2) }] }, ], text: { format: { type: "text" }, verbosity: "medium" }, reasoning: { effort: "medium", summary: "auto" }, store: false, } as any); What’s important here is the system prompt that is set in the AI model. const systemPrompt = ` You convert an internal JSON "music brief" into a concise prompt for a generative music API. Rules: – Output 3β5 short lines, max ~450 characters total. – No meta commentary, no JSON, no emojis. – Include: mood, energy/tension, BPM range, duration, loopable flag, (optional) key, instruments, texture, goal. – Avoid sharp/bright transients when asked; keep language precise and production-safe. – Never invent values not present in the brief; default only when missing. Example: "Ambient track for a focused open office. Mood: focused, energy 62/100, tension 35/100. Tempo: 84β92 BPM, loopable, ~240s. Key: D minor. Instruments: warm pads, soft piano, light shaker, subtle bass. Texture: low-density, gentle movement, softened highs; avoid sharp transients and bright cymbals. Goal: steady momentum that supports concentration without masking speech." `; Again, you can customize this system prompt to meet any of your custom project requirements before feeding it to music generation. The full source code for music prompt generation is in the libs\openai.ts file. This is an example of the generated music prompt: "Calm. Energy 60/100, tension 25/100.\nTempo: 58β64 BPM, duration ~60s, loopable. Key: A minor.\nInstruments: warm pads, soft electric piano, subtle low bass, minimal brushed percussion.\nTexture: sparse, warm, lowβmid focused with airy pads and subdued transients; avoid sharp/bright transients to prevent masking ambient noise. Goal: gentle uplift and comfort without masking background." Generate Ambient Music After the music brief and music prompt generation, the next step is to generate the ambient music. This workflow handled by the composeMusic() function (full source code in the libs\elevenlabs.ts file): export async function composeMusic({ prompt, music_length_ms = 60000, model_id = "music_v1", apiKey = process.env.ELEVENLABS_API_KEY, }: ComposeParams): Promise<ArrayBuffer> { if (!apiKey) { throw new Error('ElevenLabs API key not configured'); } const response = await fetch("https://api.elevenlabs.io/v1/music", { method: "POST", headers: { "xi-api-key": apiKey, "Content-Type": "application/json", }, body: JSON.stringify({ prompt, music_length_ms, model_id }), }); if (!response.ok) { const errorText = await response.text(); const err: any = new Error(`ElevenLabs API error: ${errorText}`); err.status = response.status; throw err; } return await response.arrayBuffer(); } Basically, the code will call ElevenLabs Music API, which uses the latest music_v1 model. However, in this project, the duration of the generated music is hardcoded to 60 seconds or 1 minute. You can edit this directly in the source code by changing the music_length_ms = 60000 code. The default audio output format from the ElevenLabs Music API is mp3_44100_128. The API also supports several other formats, for a complete list, refer to the official documentation. Database The container type used in this project is collection, and the schema for the data is defined by the interface MusicGenerationRecord code: export interface MusicGenerationRecord { timestamp: string; zone: string; temperature_c: number; humidity_pct: number; co2_ppm: number; voc_index: number; occupancy: number; noise_dba: number; productivity_score: number; trend_10min_co2_ppm_delta: number; trend_10min_noise_dba_delta: number; trend_10min_productivity_delta: number; music_brief: string; music_prompt: string; audio_path: string; audio_filename: string; music_length_ms: number; model_id: string; generation_timestamp: string; } And if you have access to the GridDB cloud dashboard, you will see these columns created based on the interface fields and their type. Save Data The save process will save the IoT data, music briefs, music prompts, audio path, audio filename, and timestamp on every successfull music generation and the function that responsible for this task is the saveMusicGeneration(musicRecord) function, initially it will check if the container music_generations exist or not and if exist than the data will be saved to the database. export async function saveMusicGeneration(record: MusicGenerationRecord): Promise<void> { if (!GRIDDB_CONFIG.griddbWebApiUrl) { console.warn('β οΈ GridDB not configured, skipping database save'); return; } await initGridDB(); try { const client = getGridDBClient(); const containerName = 'music_generations'; console.log(`πΎ Saving music generation record for zone: ${record.zone}`); // Generate a unique ID that fits in INTEGER range (max 2,147,483,647) // Use a combination of current time modulo and random number const timeComponent = Date.now() % 1000000; // Last 6 digits of timestamp const randomComponent = Math.floor(Math.random() * 1000); // 3 digit random const id = timeComponent * 1000 + randomComponent; // Prepare the data object for insertion with proper date formatting const data = { id, timestamp: new Date(record.timestamp), zone: record.zone, temperature_c: record.temperature_c, humidity_pct: record.humidity_pct, co2_ppm: record.co2_ppm, voc_index: record.voc_index, occupancy: record.occupancy, noise_dba: record.noise_dba, productivity_score: record.productivity_score, trend_10min_co2_ppm_delta: record.trend_10min_co2_ppm_delta, trend_10min_noise_dba_delta: record.trend_10min_noise_dba_delta, trend_10min_productivity_delta: record.trend_10min_productivity_delta, music_brief: record.music_brief, music_prompt: record.music_prompt, audio_path: record.audio_path, audio_filename: record.audio_filename, music_length_ms: record.music_length_ms, model_id: record.model_id, generation_timestamp: new Date(record.generation_timestamp) }; // Use the fixed insert method that now handles schema-aware transformation await client.insert({ containerName, data: data }); console.log(`β Music generation record saved to GridDB (ID: ${id}, Zone: ${record.zone}, File: ${record.audio_filename})`); } catch (error) { console.error('β Failed to save music generation record:', error); throw error; } } Read Data To view the history of the music generations, it needs to read data from the database, and this task is internally handled by the getMusicGenerations() function. const client = getGridDBClient(); const containerName = 'music_generations'; console.log(`π Retrieving ${limit} music generation records from GridDB`); const results = await client.select({ containerName, orderBy: 'generation_timestamp', order: 'DESC', limit }); The client.select() function is basically a wrapper for SQL SELECT. For the full source code for this function, you can look in the libs\griddb.ts file. Node.js Server All backend functionality is handled by the Node.js server. It exposes a few routes that can be used by the client application or for manual API testing. Server Routes Method Path Purpose GET /api/health Health check with current timestamp POST /api/iot/generate-music Generate music based on the IoT data GET /api/music/history List past generations This server also has a function to save the generated music file into the local public/audio directory. It generates a clean MP3 filename using the generateAudioFilename(zone, timestamp) function and writes the audio buffer to apps/public/audio/. The function then returns the public URL path /audio/. The generated music is served as static files, so any /audio/.mp3 URL is directly accessible over HTTP. You can open these files directly in a browser. Further Enhancements This project is a simple prototype of what we can do using IoT, AI and the GridDB database. In the real scenario, you need to wire the app with real IoT sensor

As a continuation of our previous blog: GridDB IoT Hackathon Recap (Part 1 of 2): The Online Idea Phase, we will now recount the 2nd part of the GridDB IoT Hackathon. As noted, the first part of this grand event was an online portion in which the competition was open to anybody who was willing to travel to Bengaluru in the case that they won a position as one of five finalists. You can see the gallery of all submitted participants here: GALLERY DIRECT LINK. From within the gallery you can already see which teams made it to the 2nd, in-person round. The official winners of the hackathon, as determined by the panel of judges were as follows: First Place: Deevia Software (Bengaluru) β Built a GenAI-based Enterprise Document Management Platform. Second Place: Wimera (Bengaluru) β Created an IoT Proof-of-Concept (PoC) for Industrial Machines. Third Place: VitalWatch (Maharashtra) β Developed a Preventive Risk Disease PoC. Fourth Place: Richie Rich (Bengaluru) β Designed a Financial Analytics PoC. Fifth Place: GooRoo Mobility India (Gujarat) β Built a low-cost remote Healthcare Solution PoC. During the finals, teams received direct mentorship and technical support from Toshibaβs GridDB engineers. A member of the winning team, Deevia Software, noted that the GridDB Cloud platform made it extremely easy to efficiently ingest and query time-series data under a tight deadline, allowing them to focus on designing their solution rather than worrying about infrastructure. For the remainder of the article, we will go over in small detail each project; for more details on the event itself, you can read the official press release here: https://toshiba-india.com/pr-toshiba-announces-winners-of-gridDB-cloud-IoT-hackathon-highlighting-industry-ready-real-time%20-solutions-from-across-india.aspx. The Projects Part of what made the hackathon so special was the breadth of the topics in the ideas being submitted. For instance, of the five finalists, 1 was based on generative AI, 2 were based on health care, 1 was based on industrial IoT factory work, and the another was based on the financial sector. I would like to briefly describe each project, and of course, if more information is desired, we encourage all readers to look at the hackathon gallery as it contains all projects’ original submissions. Deevia This project was unique in that it used GridDB in a way not necessarily envisioned by the GridDB team. Rather than focusing on IoT sensor data, Deevia built an AI-powered document management platform using Python, FastAPI, and React, with GridDB as the backbone via JPype. Their core insight was clever: instead of a traditional relational database, they used a container-per-file architecture where each uploaded document gets its own GridDB container, enabling parallel reads and writes without contention. OCR via PaddleOCR extracts text from scanned files, and Llama 3.1 powers semantic search and chat over the resulting knowledge base. They even used GridDB’s built-in partition expiry to handle chat history cleanup β eliminating the need for Redis or cron jobs entirely. Overall, I recommend going and reading their presentation as it was fascinating work. At a high level, the “GenAI-based Enterprise Document Management Platform” means that they can feed documents into their system, use an OCR to convert all of the text into raw text, save those results, and then use GridDB’s raw query speed to very quickly read the text data whenever a user queries the LLM which may need some data from the documents in question. Deevia also used the key-container data architecture to successfully silo off documents from users on a per-need basis (ie, if user A should not have access to certain class of documents, they simply won’t have permissions to read from that container). Overall, I recommend going and reading their presentation as it was fascinating work. Wimera Wimera, while also a strong contender, was on the opposite end of the spectrum β their usecase is exactly the kind of project GridDB was designed for. Built using Python, Node.js, and Angular on top of Azure IoT Hub and Azure Event Hub, the system ingests machine telemetry every few seconds via MQTT/AMQP into GridDB Cloud’s time-series containers. Azure Functions handle both ingestion and KPI aggregation, computing hourly and daily metrics automatically. The result is a fully connected pipeline from machine to cloud to dashboard that gives factory floors real-time visibility into machine status, energy consumption, and production counts β exactly the kind of industrial IoT use case where GridDB shines. VitalWatch VitalWatch, one of the two health submissions, paints an optimistic picture of a future where rural communities can better track and manage the growing risk of diabetes and hypertension. The stack is impressively thorough: wearable sensors transmit readings via Bluetooth to a local gateway, which publishes to an MQTT broker. A Node.js service ingests the data into GridDB Cloud in real time, while a Python service using Pandas, Scikit-learn, and TensorFlow LSTM models runs rolling averages and predictive spike detection. Doctors get Grafana dashboards for trend visualization, and high-risk events trigger SMS alerts via Twilio. Although the presentation focused on the national crisis in India, the project’s impact could truly be worldwide since diabetes is on the rise everywhere. Richie Rich Though perhaps not something immediately obvious when considering GridDB’s typical usage, financial tick data is actually a natural fit for a time-series database. The Richie Rich team built a unified portfolio tracker using FastAPI and Python on the backend with React 18, TypeScript, and Tailwind CSS on the frontend. Price data for stocks, crypto, and commodities is fetched from the CoinGecko API and persisted to GridDB Cloud’s TIME SERIES containers every 20 seconds. An XGBoost classifier trained on historical GridDB data then generates buy/sell/hold recommendations, and GitHub Actions automates weekly CSV portfolio exports for compliance and backtesting. Nifty! GooRoo Mobility India (Gujarat) This project was the other health entry and was also a very strong submission. The team built a real hardware IoT solution using an ESP32 microcontroller paired with a MAX30102 sensor for heart rate and SpO2 and an LM35 for body temperature. The ESP32 transmits readings as JSON over WiFi to a lightweight Python Flask REST API, which validates and stores the data in GridDB Cloud’s time-series containers. A frontend dashboard built in HTML/CSS/JS with Chart.js displays live vitals with color-coded alerts and historical trends. The passion from the team was palpable β they were designing an affordable remote monitoring solution to reduce costly and timely doctor visits in underserved areas, and we are looking forward to what can come of it. Conclusion Once again, we were blown away by the quality and breadth of submissions. What stood out across all five projects was how naturally GridDB’s time-series model fit into domains well beyond traditional IoT β from AI document intelligence to financial analytics to real-time patient monitoring. We highly encourage all readers to explore the full hackathon gallery here: Hackathon

