Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ scripts/*vars.sh

### Packer
*.pkrvars.hcl

### Logging

log/*
59 changes: 59 additions & 0 deletions packer/amazon-cloudwatch-agent.json.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"agent": {
"metrics_collection_interval": 60,
"logfile": "/opt/aws/amazon-cloudwatch-agent/logs/amazon-cloudwatch-agent.log"
},

"logs": {
"logs_collected": {
"files": {
"collect_list": [
{
"file_path": "${APP_DIR}/log/app.log",
"log_group_name": "/${SERVICE_NAME}/app",
"log_stream_name": "{instance_id}",
"retention_in_days": 14,
"multi_line_start_pattern": "^{"
},
{
"file_path": "${APP_DIR}/log/access.log",
"log_group_name": "/${SERVICE_NAME}/access",
"log_stream_name": "{instance_id}",
"retention_in_days": 14,
"multi_line_start_pattern": "^{"
}
]
}
},
"log_stream_name": "{instance_id}",
"force_flush_interval": 15
},

"metrics": {
"append_dimensions": {
"InstanceId": "$${aws:InstanceId}",
"AutoScalingGroupName": "$${aws:AutoScalingGroupName}"
},
"metrics_collected": {
"cpu": {
"measurement": [
{"name": "cpu_usage_active", "rename": "CPUUsage", "unit": "Percent"}
],
"metrics_collection_interval": 60
},
"mem": {
"measurement": [
{"name": "mem_used_percent", "rename": "MemoryUsage", "unit": "Percent"}
],
"metrics_collection_interval": 60
},
"disk": {
"resources": ["/"],
"measurement": [
{"name": "disk_used_percent", "rename": "DiskUsage", "unit": "Percent"}
],
"metrics_collection_interval": 300
}
}
}
}
83 changes: 83 additions & 0 deletions packer/builds.pkr.hcl
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# Render the CloudWatch Agent config on the Packer side (before upload)
locals {
cwagent_config = templatefile("${path.root}/amazon-cloudwatch-agent.json.tmpl", {
APP_DIR = var.shell_env.app_dir
SERVICE_NAME = var.shell_env.service_name
})
}

build {
name = "csye6225-webapp-image"
sources = ["source.amazon-ebs.ubuntu"]
Expand Down Expand Up @@ -50,6 +58,7 @@ EOC
cat > /tmp/app.env <<EOT
DB_CONN_TIMEOUT_MS=$R_DB_CONN_TIMEOUT_MS
SERVER_PORT=$R_SERVER_PORT
APP_DIR=$B_APP_DIR
EOT
EOC
,
Expand Down Expand Up @@ -94,6 +103,80 @@ EOC
]
}

# ------------------------------------
# Install and configure Amazon CloudWatch Unified Agent
# ------------------------------------
provisioner "shell" {
inline_shebang = "/bin/bash"
inline = [
"set -euo pipefail",
"echo '[INFO] Installing Amazon CloudWatch Agent...'",

# Download and install (Ubuntu version)
"curl -fsSL -o /tmp/amazon-cloudwatch-agent.deb https://s3.amazonaws.com/amazoncloudwatch-agent/ubuntu/amd64/latest/amazon-cloudwatch-agent.deb",
"sudo dpkg -i /tmp/amazon-cloudwatch-agent.deb",

# Create configuration directory
"sudo mkdir -p /opt/aws/amazon-cloudwatch-agent/etc",
]
}

# Write the rendered JSON content into a temporary file on the target instance
provisioner "shell" {
inline_shebang = "/bin/bash"
inline = [
"set -euo pipefail",
"echo '[INFO] Generating cloudwatch agent file...'",
<<-EOC
cat > /tmp/amazon-cloudwatch-agent.json <<'EOF'
${local.cwagent_config}
EOF
EOC
,
"if [ -f /tmp/amazon-cloudwatch-agent.json ]; then",
" echo '[SUCCESS] Config file created successfully:'",
" head -n 10 /tmp/amazon-cloudwatch-agent.json",
"else",
" echo '[ERROR] Failed to generate /tmp/amazon-cloudwatch-agent.json'; exit 1;",
"fi"
]
}

# Move the rendered config file to the official CloudWatch Agent directory
provisioner "shell" {
inline_shebang = "/bin/bash" # use bash
inline = [
"set -euo pipefail",
"sudo mv /tmp/amazon-cloudwatch-agent.json /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json",
"sudo chown root:root /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json",
"sudo chmod 0644 /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json",
"sudo systemctl enable amazon-cloudwatch-agent"
]
}

# ------------------------------------
# Verify CloudWatch Agent config & ensure log dirs
# ------------------------------------
provisioner "shell" {
inline_shebang = "/bin/bash"
environment_vars = [
"B_APP_DIR=${var.shell_env.app_dir}",
"B_APP_USER=${var.shell_env.app_user}",
"B_APP_GROUP=${var.shell_env.app_group}"
]
inline = [
"set -euo pipefail",
"echo '[INFO] Ensuring log directory exists...'",
"sudo mkdir -p \"$B_APP_DIR/log\"",

"echo '[INFO] Starting CloudWatch Agent for config validation...'",
"sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s || true",
"sudo systemctl enable amazon-cloudwatch-agent",
"sudo systemctl status amazon-cloudwatch-agent --no-pager || true",
"echo '[INFO] CloudWatch Agent validated successfully.'"
]
}

provisioner "shell" {
inline_shebang = "/bin/bash" # use bash
execute_command = "sudo bash '{{ .Path }}'"
Expand Down
23 changes: 23 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@
<artifactId>s3</artifactId>
<version>2.25.60</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>

<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-statsd</artifactId>
</dependency>

<dependency>
<groupId>net.ttddyy.observation</groupId>
<artifactId>datasource-micrometer-spring-boot</artifactId>
<version>1.2.0</version>
</dependency>

</dependencies>

<dependencyManagement>
Expand Down
2 changes: 1 addition & 1 deletion scripts/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ update_system() {

install_common_tools() {
info "Installing common tools (unzip, tar, curl)..."
apt-get install -y unzip tar curl sudo vim iproute2 >/dev/null
apt-get install -y unzip tar curl sudo sed vim iproute2 >/dev/null
}

install_java() {
Expand Down
136 changes: 136 additions & 0 deletions src/main/java/com/isaactai/cloudnativeweb/config/AccessLogFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package com.isaactai.cloudnativeweb.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
* @author tisaac
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class AccessLogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
long start = System.currentTimeMillis();

try {
chain.doFilter(req, res);
} finally {
long took = System.currentTimeMillis() - start;
int status = res.getStatus();
var log = LoggerFactory.getLogger("ACCESS");

String method = req.getMethod();
String uri = req.getRequestURI();

// --- SAFE getters (no "null" literal) ---
String labelRaw = attr(req, "access.label");
String exName = attr(req, "error.exception");

String msgSuccess = attr(req, "access.success"); // 2xx general
String msgWarnGen = attr(req, "access.clientWarn"); // 4xx general
String msgErrGen = attr(req, "access.serverError"); // 5xx general
String msgOverride = attr(req, "error.message"); // per-request detail
String code = attr(req, "error.code");

// --- fallbacks for missing attributes ---
String labelFinal = firstNonBlank(labelRaw, "Security");

if (status == 401) {
code = firstNonBlank(code, "UNAUTHORIZED");
msgOverride = firstNonBlank(msgOverride, "Missing or invalid credentials");
} else if (status == 403) {
code = firstNonBlank(code, "FORBIDDEN");
msgOverride = firstNonBlank(msgOverride, "Insufficient permissions");
}

if (status >= 500) {
String msg = combine(msgErrGen, msgOverride);
Throwable t = (Throwable) req.getAttribute("error.throwable");

// shortened the msg to prevent too long
String inlineMsgShort = abbrev(msg, 220);

if (t != null) {
log.error("{} {} [{}] took={}ms{}{}",
method, uri, labelFinal, took,
nonBlank(" err=", exName),
nonBlank(" msg=", inlineMsgShort),
t);
} else {
log.error("{} {} [{}] took={}ms{}{}",
method, uri, labelFinal, took,
nonBlank(" err=", exName),
nonBlank(" msg=", msg));
}

} else if (status >= 400) {
boolean expected = Boolean.TRUE.equals(req.getAttribute("error.expected"));
String msg = combine(msgWarnGen, msgOverride);

if (expected && isNotBlank(code) && isNotBlank(msg)) {
log.warn("{} {} [{}] took={}ms code={}{}",
method, uri, labelFinal, took, code,
nonBlank(" msg=", msg));
} else if (isNotBlank(msg)) {
log.warn("{} {} [{}] took={}ms msg={}", method, uri, labelFinal, took, msg);
} else {
log.warn("{} {} [{}] took={}ms", method, uri, labelFinal, took);
}

} else {
if (isNotBlank(msgSuccess)) {
log.info("{} {} [{}] took={}ms msg={}", method, uri, labelFinal, took, msgSuccess);
} else {
log.info("{} {} [{}] took={}ms", method, uri, labelFinal, took);
}
}
}
}

private static String combine(String general, String override) {
String g = (general == null || general.isBlank()) ? null : general;
String o = (override == null || override.isBlank()) ? null : override;
if (g != null && o != null) return g + " - " + o; // e.g., "Health Check failed - Query parameters are not allowed"
return (o != null) ? o : g; // only one exists
}

// Safely read a request attribute as String; treat missing/"null"/blank as null
private static String attr(HttpServletRequest req, String name) {
Object o = req.getAttribute(name);
if (o == null) return null;
String s = (o instanceof String) ? (String) o : String.valueOf(o);
return (s == null || s.isBlank() || "null".equalsIgnoreCase(s)) ? null : s;
}

private static boolean isNotBlank(String s) {
return s != null && !s.isBlank();
}

private static String nonBlank(String prefix, String val) {
return isNotBlank(val) ? (prefix + val) : "";
}

private static String firstNonBlank(String... ss) {
if (ss == null) return null;
for (String s : ss) if (isNotBlank(s)) return s;
return null;
}

private static String abbrev(String s, int max) {
if (s == null) return null;
s = s.trim();
if (s.length() <= max) return s;
return s.substring(0, Math.max(0, max - 1)) + "…";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@
public class ApiExceptionHandler {

// For my custom BaseApiException
// Expected exception
@ExceptionHandler(BaseApiException.class)
public ResponseEntity<ApiErrorResponse> handleBase(BaseApiException ex, HttpServletRequest req) {
req.setAttribute("error.expected", true);
req.setAttribute("error.code", ex.getCode().name());
req.setAttribute("error.message", ex.getMessage());
req.setAttribute("error.exception", ex.getClass().getSimpleName());

HttpStatus status = ex.getStatus();
ApiErrorResponse body = ApiErrorResponse.of(
status.value(),
Expand All @@ -35,6 +41,16 @@ public ResponseEntity<ApiErrorResponse> handleBase(BaseApiException ex, HttpServ
return ResponseEntity.status(status).body(body);
}

@ExceptionHandler(Exception.class) // Unexpected 5xx
public ResponseEntity<ApiErrorResponse> handleAny(Exception ex, HttpServletRequest req) {
req.setAttribute("error.unexpected", true);
req.setAttribute("error.throwable", ex); // for AccessLogFilter to print stack
req.setAttribute("error.message", ex.getMessage());
req.setAttribute("error.exception", ex.getClass().getSimpleName());
var st = HttpStatus.INTERNAL_SERVER_ERROR;
return ResponseEntity.status(st).body(ApiErrorResponse.of(st.value(), "INTERNAL_ERROR", "Internal server error", req.getRequestURI()));
}

// @Valid validation failed
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorResponse> handleValidation(
Expand Down Expand Up @@ -76,5 +92,7 @@ public ResponseEntity<ApiErrorResponse> handleBadJson(
);
}



// TODO: Fallback handler to avoid returning raw 500 errors
}
Loading
Loading